Extensions
Every palette in pal is an extension, the bundled ones included: a
directory with a pal.json manifest and an index.ts that Bun runs inside
one long-lived extension host. An extension declares palettes; a palette
lists rows, says what happens when one is picked, and can declare settings
the settings window renders and the config file keeps. An extension can
also put items on the bar ("Bar items", below).
sdk/src/protocol.ts (the types of @zcag/pal, every one with a doc
comment) is the contract; this page follows it, and where the two differ
the code is right.
Where they live
- Bundled: the names in
app/bundled.txt, each the package pal's registry serves (below, "Packages:pal-pack"), in the app's resource tree. The sources are in zcag/pal-extensions (the games in zcag/pal-games); a debug build of pal loads those checkouts beside its own (../pal-extensions,../pal-games, or the:separatedPAL_EXTENSION_REPOS) in place of the bundle, after the store, so an edit there is what runs. - The store, what
pal installand the settings window fill:~/Library/Application Support/pal/extensions/<name>/on macOS,~/.local/share/pal/extensions/<name>/on Linux ($XDG_DATA_HOME/pal/when set). One store for every config file. Not in the config directory: that is often a dotfiles checkout, and the store's install records, staging directory and the host'snode_modules/@zcag/pallink do not belong in one. - Yours, kept wherever you like: every directory in
general.extension_dirs(Config), one subdirectory per extension, for a set that lives in dotfiles.
The roots load in that order and a later one wins on a name: a directory
named like a bundled extension replaces it. The host loads every directory
under those roots that has an index.ts (or index.js), watches them, and
reloads an extension whose files change. Three things keep a found
extension from loading:
- Its protocol. A package built for a
protocolthis pal does not run (the manifest'sprotocol, below) is passed over for the same name in an earlier root; with no other copy it is listed as failed, "built for protocol 3; this pal runs protocol 2". A directory withoutprotocol(a source checkout, one you made by hand) always loads. - Turned off. An extension turned off in Settings (
[store] disabled) is listed with its switch and not loaded; amultione's instances all stop with it. requires. One that requires an extension that is missing or turned off is listed as failed ("needs weather, which is not installed") and loads by itself once that one is there.
The manifest, pal.json
Read without running the code, so the settings window lists an extension whose code fails to load.
{
"name": "hello",
"title": "Hello",
"description": "One palette, three rows, one setting.",
"version": "0.1.0",
"icon": { "tile": { "glyph": "", "bg": "teal" } },
"author": "you",
"repo": "github.com/you/pal-hello",
"settings": [
{ "kind": "text", "id": "greeting", "label": "Greeting", "default": "Hello" }
],
"palettes": {
"hello": { "kind": "list", "description": "What the palette is for." }
}
}
name: required, the directory name and the config key; lowercase letters, digits,-,_,..pal installrefuses anything else.version: required (a string).title,description,icon,author,repo: what the settings window and the store show.iconis the extension's tile ("Icons", below): a rounded square in one of twelve brand colours with a white mark; every palette wears it at the root unless the code gives the palette one of its own.settings: extension-level settings,[extensions.<name>]in the config file (SettingSpec). Every one hasid,label, an optionaldescriptionand adefault;"scope": "instance"marks one that identifies the account (below, "Instances")."required": truemarks one the extension does nothing without: left empty, the settings window lists the extension as needing setup (nothing else is guessed at, so an optional key is simply not marked)."only": { "auth": "token" }narrows that to while other settings hold those values;{ "invidious_url": "" }makes it one of two."local": truemarks one that belongs to the machine (a list of folders) and never syncs with a pal account; ahotkeyorpathsetting is local without it, everything else syncs (Config). A syncedsecretsyncs itskeychain:reference only: on a machine without the key the extension sees the setting unset, and Settings asks for it ("Add your key"). A setting only a bar item reads is that item's, declared underbar.<id>.settings(below, "Bar items"); one the palettes read too stays here. The kinds and what each adds:textandsecret(placeholder; a secret goes to the OS keychain, Config),number(min,max,step,unit;slider: truewith both bounds draws a slider, a volume say, where an older app shows the box),boolean(text, the line beside the switch),select(options, each{ id, title }),hotkey,path(pick:fileorfolder,placeholder),list(placeholder; the default a list of strings).keywords: words every palette's row at the root answers to, on top of its title and key: the short names people type for the product (["gh"]on GitHub,["ha", "hass"]on Home Assistant,["1p", "op"]on 1Password). Typing one lists the extension's palettes first; without itghscatters acrossGitHuband loses to rows that start a word with it. They name the palette's rows as well: with the titles and the config'saliasthey are the source's path, and a query word that starts a word of it is spent there, sogh palfinds the repository andtod estoniathe todo. A word only lands in the path when it starts a word of it (maildoes not reachGmail: that is what a keyword is for), and a query all of whose words land there lists nothing, soghalone is still the palettes, not every pull request in them.palettes.<key>: the palette's static description:title,description,kind,ttl,lazy,tier,keys,keywords(the palette's own, on top of the extension's),rank,settings,match,inline,fallback, and for a view paletterefreshandon([palettes.<id>].settingsin the file). The key is the palette's key in the code'spalettesobject; what goes here and what goes in the code is the next section.bar.<id>: a bar item'stitle,description,refreshschedule,keys, optional Settings-onlymocks, itsrulesand its ownsettings(below, "Bar items"). The id is the key in the code'sbarobject."strip": falsemakes it a glance-only item (below, "A card of its own").store.permissions: what the store page lists the extension as needing (network,token, the tools it runs). The OS permissions among them (accessibility,calendars,full-disk-access,input-monitoring,location) are also what the Overview reads: a missing permission is a row there only while something installed uses it.links.<route>: a deep link route the code answers, with itsdescription,paramsandconfirm(below, "Links: routes of your own").states.<name>: a state the code publishes, withstate.setor in a render'sstates(below, "States"), itsdescriptionandkind(boolean,number,string), so the States palette and the item's Settings pane show it before it is ever set.multi:truewhen the extension can run as several configured instances (two accounts, two homes; below, "Instances"). Without it a[instances."<name>@<suffix>"]in the config file is not loaded.requires: the names of extensions this one does not work without (["processes"]). It does not load while one of them is missing or turned off, and loads when it arrives; installing it from a registry installs them.suggests: the names of extensions some of its actions use (["colors", "diff"]on Clipboard). It is a declaration: pal hides nothing by itself. The extension decides what to offer while one is missing or turned off,extensions.list()saying which are installed andloaded; the Apple TV and Samsung TV remotes leave out theiroto the other remote then.controls: the controls the code provides (["volume", "power", "inputs", "outputs"]; below, "Controls"), so Settings › Groups offers the extension for them without loading it.sync: the extension's storage syncs with the user's pal account, each key merged by its rule (below, "Syncing storage"). Without it the storage never leaves the machine.leaderboards: the boards a game posts scores to (below, "Leaderboards").protocol: written bypal-pack, never by hand: thePROTOCOLof the SDK the package was built against (below, "Packages").
A name in requires or suggests that is not an extension name, or the
extension's own, is a load warning and is ignored.
Instances: one extension, several accounts
An extension that declares "multi": true in pal.json can run as
several instances: the user adds [instances."gmail@work"] to the
config file (config.md, "[instances]") and gets a second
set of palettes and bar items with their own settings, storage, cache,
frecency and links; the code and the manifest are shared. The default
instance is the bare name and everything that exists today; the other
is spelled by its key, <name>@<suffix>, everywhere a name is used
([extensions."gmail@work"], [palettes."gmail@work-inbox"],
[bar.items."gmail@work/unread"], pal://open/gmail@work/inbox,
storage/gmail@work.json).
The code never learns a new API to work: settings.get(), storage,
bar.update, push and the rest keep meaning "me", where "me" is the
instance. Every instance of a multi extension runs in its own worker of
the host (the default too, even when it is alone), with its own copy of
the module and everything it imports, so a token cached at module level
in api.ts is one instance's alone. What changes for the author:
settings: mark a setting"scope": "instance"when it identifies the account rather than configures it (Home Assistant'surl, Slack'sworkspace); it is then never inherited from the default instance, like asecretnever is. Everything else inherits:[extensions.gmail] signatureisgmail@work's signature until set there.- Palette titles: with two or more instances every palette title gets the
instance's title in parentheses appended ("Inbox (Work)"), unless the
manifest's
titlecontains{instance}, which is substituted instead ("{instance} Inbox"gives "Work Inbox"). With one instance, or for a default the user has not named,{instance}and one surrounding pair of parentheses or a flanking space are stripped ("Inbox ({instance})" is "Inbox").{instance}in a manifest withoutmultiis a load warning. - Tile: the extension's tile wears the instance's
tintand abadgeletter in the corner; a palette's own tile keeps its colour and gains the badge. The default instance keeps the plain tile. instance()in@zcag/palanswers{ key, name, title, isDefault }:keyisgmail@work(the bare name for the default and for a non-multiextension),namethe manifest's,title"Work" (none for an unnamed default). A bar item gets the same asctx.instanceinrender, so it can name its account intitle.- An effect's
push(and a bar item'smenu: { palette }) that names the extension by its manifest name, or not at all, lands on the instance it came from; another extension's name is left as written. disposeruns when an instance is removed or reloaded, before its worker is terminated. An instance whose event loop hangs is terminated after a second without touching the others.- Bar items: the strip is not marked; the core appends the instance's title to the tooltip ("3 unread (Work)") and to the popover's title, and Settings > Bar reads "Gmail (Work) › Unread". A lone or unnamed default is not marked.
- Settings: the user adds, renames, parks and removes instances on the
Extensions page ("Add another account", with the suffix slugged from
the title), or with
pal instance add gmail work --title Workandpal instance remove gmail@work; nothing is asked of the extension. Settings shows each instance's values with the inherited ones marked; a secret orscope: "instance"field reads "Set for this instance" and, empty, puts the instance on the Overview's needs-setup list ("Gmail (Work) needs token"). Removing an instance takes its tables, storage, index cache and frecency away; keychain items stay.
Bundled: gmail (two accounts: token_command, address and send per
instance), github (two accounts: token per instance, the rest inherits),
slack (a workspace per instance: workspace is scope: "instance"),
home-assistant (a home per instance: url is scope: "instance").
Where a palette is described
A palette is described in two files, and each fact has one home:
pal.jsonholds what is static and author-facing:title,description,kind,ttl,lazy,tier(below),keys(what each key does, as[{ "keys": "cmd+c", "title": "Copy the link" }]),keywords(the words the palette's root row answers to),hold(the switcher chord to suggest,"alt+tab"on Windows: applied while the user's config has noholdline, Keyboard),tap(the rows are windows, most recent first: the switcher's quick tap is this palette's, and the Window switcher feature is its chord; Windows sets it),dialog(serves file dialogs: while an open or save panel is in front, the empty root's hint opens this palette; Files sets it),front(bundle ids of the apps whose being in front puts its rows first at the root, below),remember(false: picks are not remembered and never reorder the rows, for a palette whose order is the point, such as OTP codes, an inbox or windows most recent first, or whose ids last one listing; every other palette with indexed rows learns from picks,liveones included),on(the triggers that list it again while it shows:clipboard, a copy recorded; a view's re-ask it),rank,settings, and the extension'siconandkeywords. The store and the settings window read these without running the code, and so does a registry's listing: a palette with notitlehere is its bare key in the Store and in root search.- The code holds the behaviour and what only it can know:
list,pick,detail,view,filters,placeholder,showDetail,columns, and the flagsliveandinput.
kind is the one fact both sides state, since the manifest names it and
the code implies it, and they must agree:
kind |
the code |
|---|---|
view |
a view() palette |
grid |
view: "grid" |
input |
input: true (a live input palette is input: nothing of it is indexed) |
live |
live: true, without input |
list |
none of those |
Read top down: the first row that fits is the kind. title, ttl, lazy
and tier may appear on both sides; the manifest's value is the
one served, and a code value that differs is a warning. A code value with
no manifest counterpart serves silently (the key stands in for a missing
title). lazy is the one of these a code side may want on purpose: the
bundled calendar sets it from its source setting at load (Google is a
token command and a network read, EventKit is local).
The host runs this check on every load (checkPalettes in @zcag/pal,
so an extension's tests can run it too). Nothing fails: each disagreement
is a [<ext>] manifest: palettes.<key>: ... line on stderr and an entry
in the warnings the settings window shows for the extension. A palette
the code has but the manifest lacks is served from the code alone; one
the manifest has but the code lacks is not served at all. A manifest with
no palettes key declares nothing static and gets no warnings (the
bundled scripts discovers its palettes from a config file); one that has
the key, even empty, must name every palette.
The palette id. Wherever a palette is addressed outside its extension
it is one word: the extension's name when the palette is named like the
extension, else <extension>-<palette>. So emoji's emoji palette is
emoji, clipboard's history is clipboard-history, github's prs
is github-prs. That id is the config table [palettes.<id>] (enabled,
alias, hotkey, item_hotkeys, icon, settings; see
Config) and the heading in Palettes. A
pal://open/<extension>/<palette> link names the two parts separately
instead.
tier: what the rows are at the root
Every palette's rows compete in one root search, and a big static list
would drown the rest: chr matches four hundred icon glyphs. tier says
what a palette's rows are next to everyone else's:
tier |
for | at the root |
|---|---|---|
primary |
what is reached by name: apps, windows, bookmarks, quicklinks, snippets, recent files, browser tabs, system commands, SSH hosts | ranked up (+150), at most 8 rows per palette |
normal |
what is browsed: containers, pull requests, devices, services, timers; the default | as matched, at most 6 rows |
catalog |
a big static list where any query matches dozens of rows: emoji, unicode, icons, colours, a scripts data file of 100 rows or more | ranked down (-150), at most 3 rows, and never above another palette's section that has the typed word |
A row whose name or keyword has the typed word (every query word starts
a word of it) is ranked above one that only collects the letters (chr
across Clipboard History), by two tiers' worth; a row named what was
typed leads outright, whatever the tier; what the user picks a lot
climbs (docs/config.md, "Search history") by up to a tier and a third.
A catalog's section comes after every section of the other tiers that
has the word, whatever its rows score: sync lists Home Assistant's
Sync Box entities before the glyph named sync, and a much-used glyph leads only
its own section. Under those sections the scores decide again, so
smile still leads with the emoji over rows that only collect the
letters. The rows a cap
leaves out are behind a muted "12 more in Emoji" row at the end of the
section; Enter on it opens the palette, where nothing is capped. One
row for one thing: two palettes of one extension that list the same item
under the same id (Today beside My Schedule, Recent Notes beside Notes,
Unread beside Chats) put it under the better-ranked section only. The
constants and the measurements are in core/src/index.rs under
EXACT_BONUS and in notes/decisions.md ("Root ordering").
The manifest declares it ("tier": "catalog" under palettes.<key>);
the code may say tier too. The user overrides it with [palettes.<id>] tier = "primary" and the caps with [general] root_caps
(Config). The empty query is untouched: use, then
the listing order, no caps.
front: first while its app is in front
"front": ["com.google.Chrome", "com.apple.Safari"] under
palettes.<key> names apps by bundle id. While one of them is the app in
front (the panel never takes that over), the palette's rows that have the
typed word lead the root, ahead of a row elsewhere named exactly what was
typed: in Chrome, github is the GitHub tab before the GitHub
extension. A row that only collects the letters gets nothing, so the
rest of the root still answers what no tab has. The empty query is
untouched. Browser Tabs names the browsers. macOS only (the app in front
is the front_app state); the user turns it off per palette with
[palettes.<id>] front = false, Settings' "App in front" switch.
The code, index.ts
The default export is { palettes: { <key>: Palette } }. A palette
(Palette in @zcag/pal):
import { defineExtension, settings, type Item } from "@zcag/pal";
export default defineExtension({
palettes: {
hello: {
title: "Hello", // the section label at the root; the manifest's tile is its icon
list: (): Item[] => [ // rows; indexed unless `input: true`
{ id: "greet", name: "Hello, world", subtitle: "a row", icon: "👋", actions: [{ id: "copy", title: "Copy" }] },
],
pick: (id, action) => ({ copy: "Hello, world" }), // an Effect
},
},
});
defineExtension only type-checks the object where it is written (it is
satisfies Extension under a name an editor completes); the bundled
extensions use satisfies directly. With the manifest first,
defineExtension(manifest, { palettes: { ... } }) (import manifest from "./pal.json" with { type: "json" }), the palettes are checked against
what pal.json declares: a declared key left out or an undeclared one
written is a type error. A manifest written inline (as const) also pins
each palette's shape to its kind (a view must have view(), a live
one live: true); a JSON import widens kind to string, so that part
is left to the load-time check.
list(query?, ctx?)returnsItem[], sync or async. Withoutinput: truethe host lists once, the core indexes the rows, and the root search matches them like everything else; the palette is listed again when its settings change, on⌘R(ctx.refreshis thentrue, so a cache of the extension's own steps aside), or afterttlseconds at the next start. Withinput: trueit runs on every keystroke inside the palette and the root has only the palette's own row (a calculator); the search row sweeps while the latest keystroke's listing is unanswered, and an earlier one's late answer is dropped. A listing that waits on something slow can show rows early:ctx.partial(items)draws them whilelistkeeps working, each call replacing the last, and whatlistreturns replaces them all. Show what is ready first (cached rows, the fast one of several sources, rows before their pictures) and return the whole. It exists only inside the palette's own level (ctx?.partial?.(items)), and only the latest keystroke's rows are ever drawn, so a superseded listing's partials are harmless. When to use it and how: "Slow listings" below.pick(id, action?, ctx?)returns anEffect:copy(text, or aCopyTextfor a secret, see the note below),copy_files(a list of paths: the files themselves, see the note below),open(url or path),paste({ text }, or{ entry }for a clipboard history id),focus(a window id),layout(aWindowLayoutRequest:nameone of the window layouts,ida window fromwindows.list(), else the focused one; the panel hides first and the HUD names the layout),space(a space id fromwindows.spaces(): the panel hides, then the space comes in front),hide,toast({ title, message?, style? },stylesuccessordestructive),hud(a line in the HUD capsule after the panel hides;copyalone shows "Copied" there),large_type(the text across the screen, below),dialog(a path typed into the open or save panel in front, below),keep(stay open and list again),push(drill into a palette withargs;titlenames the level's crumb, the folder being browsed rather than the palette's title;placeholderits search box, "Search apps" where the palette's says "Search files";queryis typed into it),show(a detail-only level: aDetailplus atitle; withactionsit is a row's preview, below),view(a render tree, below),form(a prompt with fields, below).ctxcarriesfilter, theargsof thepushthat opened the level, on a form's submit itsvalues, and on a multi pickids(below).- A bare
→,←orbackspaceon a list row while nothing is typed runs the row's action carrying that key as itsshortcut("right",["left", "backspace"]);←andbackspacealso reach such an action on any row of the level, so a..row's Go up works from anywhere in a browsed folder. With text in the box the keys keep their native effect. - Several rows at once: an action with
multi: trueis offered while rows are marked (⇧↓,⌘-click,⇧-click for a range, andTabor, with nothing typed, a barexin a palette that declaresmulti: trueitself), andEnterruns it as onepick(id, action, ctx)whereidis the cursor's row when it is marked, else the first marked, andctx.idsevery marked one. Marked rows stay marked while the query filters them off screen.- What is offered is the
multiactions every marked row carries (by id), in the addressed row's order. A row that should be markable either way next to its opposites carries both: Gmail's unread row has "Mark as read" on⌘↵and "Mark as unread" at the end, a read row the other way round, so a mix of read and unread offers both. Ahiddenaction is never run over marks. An action withoutmultiis single-row only: it is not listed, and its key says so rather than run it on one row. ctx.idsis always there on a pick,[id]for a single one, so one loop serves both:const ids = ctx?.ids ?? [id](the??only for a test callingpickwithout a ctx). Frecency records nothing for a multi pick.- One effect answers the whole pick, so the effects that act on a target
take several:
opentakes a list of urls or paths (each given to the OS opener in order),copy_filesa list of files, and acopyjoins the rows' text one per line. - When the single pick already does the right thing per row,
eachId(ctx?.ids ?? [id], (x) => pickOne(x, action), "notifications")from@zcag/palruns it once per id in turn and folds the effects: everyopenin one list, the copies one per line, the first toast with the count, the first failure instead when one failed (foldEffectsfolds effects you already have). - A multi action that answers
{ form }(Move to…, Snooze…) gets the marked ids back on the form's submit, inctx.idsbesidectx.values. - The bundled Files (open, reveal, the copies, trash), Windows (close,
minimize), Clipboard (copy joined, delete), Bookmarks (open), Gmail and
WhatsApp (read, unread, archive, star), the notifications of GitHub,
Slack and Tela (mark read), Docker (stop, start, restart, remove),
Processes (kill), Applications (quit), Browser Tabs (close, copy) and
the history rows of Shell, Translate, GIFs, Google, Speedtest, YouTube
and Colors (remove) do this. A
confirmon a multi action gets the count under its question ("On 3 selected rows"). - A view's rows mark the same way (a bar popover's inbox): see
markunder view nodes.
- What is offered is the
dialog: the path is typed into the open or save panel the app in front has up (macOS: its Go to Folder sheet,⌘⇧G, the path pasted, Return; GTK:Ctrl+L), the panel hidden first; the HUD says which panel took it or that none was up.dialog.current()from@zcag/palanswers{ app, pid, kind: "open" | "save", title? }ornull, read once per panel show and cached (alistmay ask it on every keystroke), so a row can lead with the action only while a panel is up, as Files does.- A secret is a concealed copy:
{ copy: conceal(password) }, that is{ copy: { text, concealed: true, clear_after: 30 } }. The text goes on the clipboard marked for clipboard managers to skip (org.nspasteboard.ConcealedTypeon macOS, the KDE password-manager hint on Linux), never enters pal's own history, and afterclear_afterseconds the previous clipboard is put back (or the clipboard emptied) if the secret is still on it; the HUD says "Copied, clears in 30 s". The bundled 1Password and Verification Codes palettes copy this way;conceal(text, 0)conceals without the clear. large_type: the panel hides and the text is shown across the screen in a type size fitted to the width (a run of digits grouped, a code-like text in monospace), until any key, a click, or 8 s: an OTP, an IP, a licence key read from across the room. Blank text shows nothing; 400 characters is the cut.detail(id, ctx?): the detail pane's content for a row, asked lazily.- Palette flags:
live(the rows change between shows: re-listed on every show, not twice within 2 s; with attl, only once the last listing is older than that; picks still rank them unless the manifest says"remember": false),view: "grid"+columns,placeholder,showDetail,filters.ttlandtierbelong in the manifest ("Where a palette is described", above); the code's is a fallback. Without attla palette is listed on every load: most bundled TypeScript extensions have none, they are cheap;scriptsdefaults its tables to an hour (Scripts). lazy: true(manifest or code, the manifest wins): the palette's first listing of a run waits for the first time the panel shows instead of running at process start. Its cached rows still restore into the root at startup, so nothing is missing from the search; only the refresh moves. From that first show on,ttlandliveapply as for any palette, and an extension reloaded later lists at once. The only moment such a palette has no rows is a first run before the panel was ever shown. For a listing that reaches the network (Slack, GitHub, Home Assistant, tela, Hue) or reads something private (Messages for OTP). The core logsindex <ext>/<palette> lazy, waits for a showat load and lists it on the show withwhy=show.lazy: "visit": the first listing waits for the user's first visit to the palette (the panel inside it, or the sidebar showing it), not the first show, and a reload before that visit still waits. For a listing that prompts: 1Password authorisesopper app with its own window, and pal asks for nothing before the feature is used (the same rule as its own permissions: nothing at launch, the ask at the feature). The price is a fresh profile's root search not finding those rows until the palette was opened once; the cache carries them from then on. Loggedlazy, waits for a visitat load, listed withwhy=visit.- The root's sections a palette may take part in, all optional:
- Inline results:
inline: truewith amatch(a regex, a regex source, or a predicate(query) => boolean; the manifest may carry the string form underpalettes.<key>.matchfor the store). A root query the match accepts runslist(query, { inline: true }), and its first five rows show at the root under the palette's title, above the index's hits, with their own actions (Enter on a calc row copies the result, on a colour row opens the picker, on a path row opens the file). The core asks 120 ms after the last keystroke, after the local hits painted, and drops a reply for a query that moved on; a list slower than 1.5 s is left out. A palette that lists hints for the empty query returns[]whenctx.inlineis set and nothing matched. Make the match tight: it decides how often the list runs. - Fallback rows (
fallback): what a query the index has nothing for can still do.fallback: trueadds an "Ask<title>" row that opens the palette with the query typed; a string is that row's title with{query}filled in ("Search Files for “{query}”"); a function(query) => Item[]answers the rows itself (quicklinks lists every{query}link filled in), picked throughpicklike any row. The manifest may declaretrueor the title.general.fallbacksorders the rows;general.fallbacks_alwaysshows them under the hits too. - Late fallback rows (
lateFallback: (query) => Item[]): rows that join the fallback section after it painted, under Search the web, for what needs the network (Google Search's suggestions), which afallbackfunction would make the whole section wait on. The page asks the host'sfallback/lateonly once the section shows (nothing matched, orfallbacks_always), so a query the index answered never reaches it, and drops a reply for a query that moved on. - Suggestions (
suggest: () => Item[]): a few rows for the empty root's "Now" section, asked on every show of the empty root and after every pick from it (the next event, the running timer, what is playing, what is on the clipboard). Keep it fast and cached: it runs with every other palette's, each on a 1.5 s budget, and a slow or failing one is left out. A row's ownsectionnames its section ("Clipboard"); rows without one go under "Now".general.noworders the palettes.
- Inline results:
Effect.pushmay carryquery: the level opens with that text in its search box (a fallback row hands the root query in this way). A push carryingargs: { create: ... }intosnippets(a string, the text) orquicklinks({ name?, url?, keywords? }) lists one row whose form comes pre-filled with it: the way another extension hands a snippet or a link over to be saved.- An
Itemhasid(stable),name,subtitle,icon,keywords,url,accessories,detail,actions(first is Enter, second ⌘Enter; an empty list is an inert hint row),complete(Tab puts that text in the search box and the level lists again: a suggestion completed into the query, at the root too; it wins over the level's own Tab),hero(trueon the one row that is the answer to what was typed: a sum, the weather now, a translation; the list draws it as a headline, taller, its name in the design's display type over its subtitle; put it first. An app that predates it draws a plain row, so it needs no newer pal). Any other key rides through untouched (asectionfor the empty root, whatever the extension wants to keep on the row) andpickdoes not get it back. An accessory is right-aligned on the row:{ text },{ tag, color? }(a badge in the tag palette),{ date }(an ISO string or unix ms, shown relative: "3 h ago") or{ keys }(a shortcut drawn as key caps).detailis{ markdown?, metadata? }: markdown (no raw HTML;icon://images work) over a list of{ label, value?, tags?, link? }lines;detail(id, ctx)on the palette answers the same lazily. AnActionhasid,title,shortcut(below),style: "destructive"(drawn red),confirm(a question asked first, the action's title as the go-ahead),multiandhidden. Rows that all do the same things declareactionsonce on the palette instead: a row without its own gets those (the icons catalog's eleven thousand rows carried four copies ofCopy glypheach, half the listing on the wire).
Settings reach the code resolved: the manifest's defaults with the file's values on top, kept current on every config change (an extension whose values changed is listed again).
Icons
Every icon field (icon in the manifest, on a palette, on a row, on a bar
item's menu rows) takes the same forms:
| Form | Draws as | For |
|---|---|---|
{ tile: { glyph, bg } } or { tile: { svg, bg } }, or tile(bg, mark) from @zcag/pal |
a rounded square in the brand colour bg (or a product's own #rrggbb), the mark white (or fg): one Nerd Font glyph, or SVG path data (d only, no markup, 5000 bytes at most) drawn in a box by box square (16 by default); badge puts one or two characters in its corner (badged(icon, { tint, badge })) |
the extension's own icon: icon in pal.json, which every palette wears at the root, in the crumb, in Settings and on the store |
{ glyph, color }, or tinted(glyph, color) |
the glyph in a brand colour or a hex of your own | a state on a row: an open pull request's octicon in green, a merged one's in violet |
a Nerd Font codepoint ("\u{f0868}", or xdg("dialog-error")) |
the glyph, tinted in the palette's tile colour; the text colour on a palette without a tile | most rows |
| an emoji | the platform's colour emoji | rows whose content is the emoji |
a hex colour ("#4F46D6") |
a dot in that colour | a swatch |
{ app: path } |
the application's own artwork | apps, windows, processes |
{ image: url } |
the picture, icon:// or data:image/ |
artwork, avatars, a diagram the extension drew |
nothing, with a url on the row |
the site's favicon; the globe mark, in the palette's colour, until it loads | bookmarks, quicklinks, tabs |
The brand colours (TILE_COLORS, the --pal-brand-* tokens, each with a
light and a dark value): red orange amber green teal cyan blue indigo violet pink slate ink. Pick the one the thing is known by (Slack
violet, a timer amber); the neutral family (apps, windows, files,
processes, services, system, scripts) is slate.
An extension that is a product or a service (GitHub, Gmail, Spotify, Docker, Home Assistant) wears that product's logo instead, on the product's own colour:
{ "tile": { "svg": "M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0z…", "box": 24, "bg": "#1ED760", "fg": "#000000" } }
svgis the path from Simple Icons (CC0), copied into the manifest, so nothing loads at run time;boxis 24, the box Simple Icons draw in (at most 64).bgis the brand's colour as#rrggbb(Simple Icons'hex). It is the same in both themes, as the brand is; the tile's hairline ring keeps a near-black one (GitHub's) a square on the dark panel.fgis the mark's colour,#rrggbb, white when absent. Set it where the brand draws its mark dark on its colour (Spotify's black on green), or where white would not read on a light brand colour.- The rows of a palette with a logo tile are tinted in the brand colour nearest the logo's hex, which has a light and a dark value.
- An instance's tint replaces
bgand takes the mark back to white.
A logo is only for the extension that is that product: pal's own tools
keep a glyph tile, and so does one that speaks to several services
(calendar, maps, translate, speedtest). A brand Simple Icons does not have
(Slack, removed on the owner's request; Apple Shortcuts) keeps its glyph
tile: never draw a logo by hand. app/scripts/brand-icons.ts writes the
bundled extensions' logos from the simple-icons package (--check
reports a manifest that has drifted from it). A row
with a real picture shows it (an app's icon, a favicon, an avatar, album
art, a colour swatch) and only falls back to a glyph without one. The
host checks every icon on load (checkIcon): a tile off the palette or
with markup for its svg is a warning in Settings and the palette's own
icon falls back to the manifest's. Glyphs listed as content (a catalog,
a grid) are not tinted: they are what the palette lists.
Slow listings: show what is ready
An input palette whose rows wait on anything slow (a web API, several
requests, a subprocess, a picture per row) shows what it has early with
ctx.partial(items) instead of making every keystroke wait for the
slowest part. This is the default for a new palette, not an
optimisation for later: if list awaits the network, it streams.
- What first: cached or local rows (your own items, a recent list), the fastest of several sources, rows before their pictures, avatars or resolved names. One partial per stage that adds rows; the host folds a burst into its latest anyway.
- Stable rows: a row keeps its
idfrom the first partial to the answer, and later rows join below what is shown. A section that answers early for a lower place waits for the ones above it, so nothing moves under the cursor (the panel keeps the cursor on its row by id, but rows jumping still reads as flicker). A placeholder icon that becomes a thumbnail is fine; a title that changes is not. - The answer is the last word: return every row, the partials'
included. Nothing sent after
listreturns is drawn. - Superseded keystrokes: only the latest query's rows are drawn, but a palette with its own debounce or sequence counter checks it before a partial too, so a listing it already knows is stale sends nothing.
- Cost guards stay: streaming changes when rows show, not what is fetched. A paid API still waits for the query to rest (Google's results).
- Tests:
host.listStream(ext, palette, query)in the host harness answers{ items, partials }; check the first partial is the fast part and that each partial is a prefix of the answer.
github (your cached items, then each search tier in order)
and files (names, then the matches inside files a batch at a
time) are the reference shapes.
View palettes: a render tree
A palette can draw instead of list: give it view(ctx) in place of
list, returning a View, and pal opens it as a view level. The
games 2048 and Wordle are (pal-games: 2048/, wordle/); Blackjack, Crossword,
Minesweeper, Night Parade, Snake II, Solitaire, Sudoku, Typing, Vortex and Yahtzee draw their own page instead, a
surface node (each one's surface/ in pal-games),
and so do Flashcards (flashcards/surface/ in pal-extensions)
and atc's terminal (atc/surface/).
The tree is built from a fixed vocabulary the app draws with its own
tokens, never HTML, so a view looks like the rest of the panel in both
themes. The one exception is an extension's own page, a surface node
(below, "Surfaces").
export default {
palettes: {
table: {
title: "Table",
view: () => ({
title: "Your turn",
keys: "actions",
tree: { type: "stack", padding: 4, gap: 2, children: [
{ type: "text", value: "Dealer", style: "muted", size: "xs" },
{ type: "stack", direction: "row", gap: 1, minHeight: 80, children: [
{ type: "image", key: "d0", src: svgDataUrl, width: 56, height: 80, transition: { enter: "slide-up" } },
] },
{ type: "badge", text: "17", color: "blue" },
] },
actions: [{ id: "hit", title: "Hit", shortcut: "h" }, { id: "stand", title: "Stand", shortcut: "s" }],
}),
pick: (_id, action) => ({ view: nextTree(action) }),
},
},
} satisfies Extension;
- In a view level the search input is gone: the view's
titlestands in its place, the footer shows the first action and "Actions ⌘K", ⌘K listsactionswith their keys, Escape (and ⌘⌫) leaves. Enter runs the first listed action, ⌘Enter the second, a modifier combo the action carrying it asshortcut. Withkeys: "actions"a bare key does too: a letter or digit, a symbol as it was typed (#,+,-,=),space,backspace,delete,tab,up,down,left,rightasshortcut; a shifted arrow arrives as the comboshift+up(andshift+tablikewise), and one no action carries runs the plain arrow's, so a picker's big steps cost no second binding.shortcutmay be a list of alternatives (["up", "k"]): any of them runs the action, ⌘K draws the first and the rest faintly.hidden: truekeeps an action out of ⌘K, the footer and the Enter / ⌘Enter pair; its key still runs it (Wordle's 26 letters), so it must have one, or a node that runs it on a click (action, below). - A line of text:
input: { value?, placeholder?, submit, cancel? }on the view turns the search row into a text field (the caret at the end ofvalue, focused) for as long as trees carry it. Typing goes to the field, bare keys are typing, arrows move the caret, a modifier combo still runs its action. Enter picks thesubmitaction with the text asctx.values.input, Escape thecancelaction (or leaves the level when there is none); both must be actions of the view (checkView). Answer with a tree withoutinputto close the field (the query clears, the view's document takes the keys again), or with one to keep it: avaluethat differs from the previous tree's replaces the text, the same value leaves what was typed alone. Keys that queued while the tree with the field was on its way are typed into it. The bundled picker opens the field from a hidden action on the first digit or#typed and answers with that character asvalue, so a notation is typed without a mode key. - One pick at a time, and the search row sweeps meanwhile; a key pressed while the reply is on its way queues (four at most, the rest dropped) and runs against the tree the reply brings, so fast typing loses nothing and a key meant for a board that is gone lands on nothing. The queue is dropped when the level changes.
- A pick from the view is
pick(id, action, ctx)with the view'sid(viewunless the view sets one) and the action's id. Answer{ view }and the level's tree is replaced in place; any other effect works as from a row (toastshows over the view,copyhides). From a list row,{ view }pushes a new view level.ctx.argsare thepushthat opened the level,ctx.filterits filter. - Action ids may not start with
pal:(the shell's own); the host refuses such a view (checkView, also exported for an extension's own tests). A tree is refused past 2000 nodes or 24 levels deep, when two siblings share akey, when twomovenodes share one anywhere, and when a node names a colour, fill, surface, entrance or exit the app does not draw. - The palette is reported
input: true(nothing of it is indexed; the root has only its own row) withview: "view". It still declaresicon,titleand settings like any other;detailandfiltershave no meaning for it.
The vocabulary (ViewNode in @zcag/pal; every node may carry key,
transition, action and selected):
| node | fields | draws |
|---|---|---|
stack |
direction row/column, gap and padding in 4 px steps (0..6), align start/center/end/stretch, justify start/center/end/between, grow, flex (a weight: this share of the parent's free space against its flex siblings, so a row of weighted stacks divides the width in proportion at any panel width), width / height px (a box of that size), minHeight px, surface sunken/elevated (a well behind a board, a card behind stats; give it padding) or a hex colour of the extension's own (the box is that colour; black or white ink by contrast once its alpha is over 0.5, the panel's ink under a faint tint), radius, children |
a flex box; with a hex surface a card in that colour: a room tile. A weighted or sized stack clips what does not fit, as a tile does: Disk Space's treemap is a height board of flex strips of flex boxes |
text |
value, style title/headline/body/muted/mono/number/glyph, size xs..xl, weight regular/medium/semibold, color (tag palette, accent, success, destructive, muted, faint), width / minWidth px (a column that lines up; a run with a width clips instead of wrapping), align start/center/end inside it |
one run of text; headline is the view's answer (a popover's next meeting), in the answer row's type (Item.hero): the design's display face, larger; glyph draws the value in the bundled symbols font, so a Nerd Font glyph (\u{f0369}) stands in a popover or a card as a mark next to text, at the size of size, never wrapped (OTP's empty popover carries its message mark this way) |
image |
src (icon://… or data:image/…, anything else is not shown), width/height px, mask rounded/circle, alt, dot (a tag colour) |
a picture the extension made or the app's icon scheme serves; with dot a presence dot on its bottom-right corner, ringed by the panel: an avatar (green active, grey away, red do not disturb) |
tile |
width/height px, text, sub (small, under the text), color (tag palette, neutral (default), accent, or a hex colour of the extension's own: #rgb, #rgba, #rrggbb, #rrggbbaa), fill solid (the colour, the panel's background as ink; solid neutral is paper, the elevated surface), soft (the tint, the colour as ink; default), outline |
a rounded box with the tokens' colours, so it follows the theme: a game tile, a keycap of an on-screen keyboard, a stat. The type is tabular, scales with the box, gets heavier as it grows and shrinks to fit the text; under 44 px the box takes the control radius. A hex colour paints the box with itself whatever the fill (a hairline in it for outline), takes black or white ink by contrast, and shows a checker through a translucent one: a swatch |
gradient |
width/height px, fill (a hex colour under everything), layers (each stops: two or more hex colours, alpha allowed (#ffffff00), spread evenly along direction right (default), down, up or left; in paint order, a later layer composites over an earlier one), marker { x, y } in 0..1 |
a box of CSS linear gradients with the tile radius and, with marker, a ring at that point drawn to read on any colour: a hue strip (seven stops round the wheel), the classic saturation/value plane (fill the pure hue, white to transparent rightwards, black to transparent upwards) |
badge |
text, color (grey, blue, green, amber, red, violet, pink, teal) |
a tag, as on a row |
divider |
a hairline (vertical in a row) | |
spacer |
size px, else the free space |
space |
progress |
value 0..1, width px, color (tag palette or a hex colour; else the accent) |
a bar |
slider |
value 0..1, width px (else the free space), color as for progress, label (for a screen reader) |
a level the user sets: a 6 px track with the fill and a round thumb at its end (role="slider"). The keys move it through the view's own actions; with action, a click runs that action with the clicked fraction as ctx.values.value ("0.620") |
switch |
on, color (tag palette; else the accent), label |
the system's switch pill, the knob slid over when on; with action a click runs it |
keycap |
keys in the shortcut spelling (h, cmd+k, up) |
key caps, as in the footer; with action a click runs what the cap names |
surface |
src, a page in the extension's folder (surface/index.html; relative, no ..) |
the extension's own page in a sandboxed frame that fills the body, one per view ("Surfaces") |
Two fields every node takes. action: the id of one of the view's
actions; a click (a tap) on the node runs it as its key would, so a
room tile toggles the room and a keycap runs what it names. The node is
drawn as a control (a pointer, a lift on hover), and a hidden action
some node runs needs no shortcut. A click inside nested controls lands
on the innermost. selected: an accent ring on the node's box, the
keys' cursor when arrows walk a grid; one per tree is the idea. Both are
checked (checkView): an action naming no action of the view is refused.
mark: the id of the row the node draws (a message, a notification),
unique in the tree. The shell then marks those rows as it marks a list's:
⌘-click toggles one, ⇧-click marks the range from the last marked (else
from the selected row), and a shifted arrow marks the selected row and
the one the view's arrow action lands on (the plain arrow's action runs,
so the extension still owns the cursor). A marked node is tinted with an
accent bar; the footer counts them. While any is marked only the view's
multi actions are listed (its hidden ones still route their keys, so
the arrows move on), Escape clears the marks first, and a multi action
runs once with every marked id in ctx.ids (the cursor's first when it is
marked), after which the marks go. Ids a new tree no longer has drop out
of the marks. The extension keeps no selection state at all: it draws
mark, declares multi, and reads ctx.ids.
Preview: a row read in full before deciding what to do with it. The
view declares { id: "preview", title: "Preview", shortcut: "space" }
(and a keyHint("space", "preview", { action: "preview" }), so a click
opens it too) and answers it with Effect.show carrying actions: the
SDK's preview(detail, title, view.actions, ids) builds it from the same
Detail the palette's pane shows and the view actions named in ids (what
works on that one row; multi dropped). The shell draws the detail as a
level of its own and lists those actions: their keys work there, bare
ones too (m marks read), Enter is the first, and a run is picked on
the view as from its own keys (the cursor is still on the row). Its answer
closes the preview and lands on the view (a next tree replaces the view's,
a keep re-renders the item); a failed action leaves it open with the
toast. Space or Escape goes back without a pick. Gmail, WhatsApp,
Slack and GitHub's popovers do this. The compact panel's ⌘I is the same
level for a list row with details, since no pane fits beside it.
Unknown node types are skipped, not errors, so a newer extension still
draws on an older app. key makes a node the same node across trees: the
app keeps its DOM, and animates one whose key is new or gone per
transition: enter fade, slide-up (a 6 px rise, --pal-motion-slide)
or slide-down / slide-left / slide-right (the node arrives from that
side), flip (a horizontal unfold), pop (a scale from 0.8, for a merged
tile or a typed letter); exit fade (default) or none; delay
staggers the entrance in steps of 80 ms (0..8). move: true animates
layout: when the key was in the previous tree at another box, in another
cell or another parent, the node slides from that box to its new one
(--pal-motion-move, a FLIP over the DOM the app keeps) instead of
entering, and the stack it left draws no exit for it; a 2048 tile keyed by
its id slides across the board, a split's card glides to its new hand. A
move key must be unique in the whole tree. Durations and easings are the
tokens'. A row whose keyed children come
and go wants a minHeight so the layout holds still; a cell whose tile
moves out wants a stack of its own holding an outline tile meanwhile.
Surfaces: a page of your own
A view whose body is the extension's own HTML page, for whatever a tree
cannot do: a card under the pointer, a drag, 60 frames a second, a
terminal. Any extension may use one; the vocabulary above is the easy
default, since it follows the theme, the density and the keys by itself,
and a page has to do that work on its own (the kit hands it the tokens).
The design, security model included, is docs/design/game-surface.md;
the smallest one is examples/surface-extension/. The bundled
Blackjack, Crossword, Minesweeper, Night Parade, Snake II, Solitaire,
Sudoku, Typing, Vortex and Yahtzee are games on it, Flashcards a study
session, atc's Terminal a live terminal (xterm.js, its bytes relayed by
the extension).
import { defineExtension, storage, surface } from "@zcag/pal";
export default defineExtension({
palettes: {
snake: {
title: "Snake",
view: () => ({ title: "Snake", tree: { type: "surface", src: "surface/index.html" }, actions: [{ id: "new", title: "New game", shortcut: "cmd+n" }] }),
pick: () => {},
// `pal.send(msg)` from the page; a value returned is its reply.
onMessage: async (msg) => { const m = msg as { score: number }; await storage.set("best", m.score); return { best: m.score }; },
},
},
});
<!-- surface/index.html -->
<script src="/__pal/surface.js"></script>
<script type="module" src="./main.ts"></script>
- Files. The page and what it loads come from the extension's folder
at
ext://<extension>/..., so relative URLs work. A*.tsis served as JavaScript (Bun's transpiler on that file, no bundling), soimport { step } from "../game.ts"runs the same rules your host tests check;import typefrom@zcag/palis fine, a runtime import from it or a package is not. Every response carries a CSP: the page's own origin only (no network, no eval, no inline<script>; inline style is fine; WebAssembly compiles, protocol 8). A file of an extension named inrequiresis at__ext/<name>/<path>on the same origin (protocol 8), for a runtime shared by several games: reach it by a relative URL,../__ext/flash/ruffle.jsfromsurface/index.html. A bundled extension shipssurface/and the*.tsbeside itsindex.ts(build-extensions.sh). - The kit at
/__pal/on every origin:surface.js(include it first; it setswindow.pal), the Kenney card deck (/__pal/cards/AS.png,10H.png,QD.png,joker.png,back-blue2.png; 420 by 570, draw at 14:19), andtokens.css. window.pal(SurfaceKitin@zcag/pal:declare const pal: SurfaceKit):send(msg)toonMessageand its reply,on(fn)forsurface.post(msg)from the extension,onAction(fn)for the view's actions,storage.get/set(the extension's own storage),settings()andonSettings(fn),title(text)for the title line,onTheme(fn),onShown(fn)/onHidden(fn)(pause the loop),ready()once the first frame is drawn (the app shows the page from then on);storage.onChange(fn)for a value sync brought in (below, "Syncing storage"),score(board, value, { replay }),leaderboard(board, { period, anon })andreplay(board, key)for the game's boards,account()andsignIn()(below, "Leaderboards"). Every--pal-*token is set on:rootanddata-themeon<html>, again on every flip.- Actions keep their place: ⌘K lists them, the footer shows the
first. A picked action goes to the page's
onAction, not topick; the page tells the extension what it needs to (pal.send). - Keys are the page's while the level is up (the frame has focus),
except Escape (leaves, as anywhere), ⌘K, and any cmd combo the page does
not
preventDefault, which act as the panel's own. Play one-handed on the arrows and Enter, with key hints on the page. - Size. About 720 by 390 CSS px, 560 wide in compact mode, and it can
change: fit the viewport, no page scroll, draw at
devicePixelRatio. - A clock. A game that shows a running time declares a
clockboolean (label "Clock", default on) and a "Hide the clock" action (T, or ⌘T where letters type) that writes it through the extension, as the bundled games do: a ticking number rushes some players. Hidden, the time still counts and the finish still shows it. - In a browser.
bun host/src/surface.ts <extension folder> [port]serves the folder the same way (.tstranspiled, the kit mapped), and the kit runs as a stub outside pal: storage inlocalStorage, settings from?settings=<json>, the theme from?theme=dark|lightor the OS. A page's TypeScript is typechecked against the DOM by the extension repo'stsconfig.surface.json(itsmake testruns it). - On the web. A game that needs nothing from its extension (every
pal.sendonly tells it something, and the page does without the reply) can say"play": truein itsstoreblock, and play.cagdas.io serves it the same way, in a frame loaded with?web&play=1and the settings at their defaults; its page on pal.cagdas.io links there. The game page there is the kit's other half, as the app is: it keeps the storage (synced to the player's account when they sign in) and answersscore,leaderboard,replay,accountandsignIn, so a game's progress and boards are the same in the browser and in pal. Framed with?webalone the kit is the stub above (a score kept as is, no board, no replay, signed out). Keep the page's own keys off ⌘ combos the browser keeps (⌘T, ⌘N, ⌘W).
Live views: push and pull
A view is drawn on open and on every key; a view that follows something outside the panel (a song's position, a light a switch just dimmed, a counter) needs to change while nobody presses anything. Two halves, and they compose:
Push: view.update. From @zcag/pal, view.update(spec) replaces
the open level's tree in place, exactly as a { view } answer to a pick
does: the keyed transitions run (move, enter, exit), the DOM of a kept
key stays, the text field and its caret are untouched. spec is a
ViewNode (the tree alone; the level keeps its actions, title, keys and
input) or a whole View (those replaced too). Inside view or pick
the palette is known; from a timer or a stream pass { palette }, and
{ id } when the palette's view answers to several things (Hue's light
view is one level per light, its View.id the light's id; an update
without id reaches whichever is open). Pushes to one level closer than
VIEW_UPDATE_MIN_MS (33 ms) coalesce, the last winning, so a stream may
push as it likes. The core drops a push while no such level is open (one
log line per target until one lands), so a loop may run a beat past the
level without harm; still, stop it:
import { view } from "@zcag/pal";
let timer: ReturnType<typeof setInterval> | undefined;
view.onShown((ev) => { // ev: { extension, palette, id } (or { bar } for a bar item's own popover level), compact: true in the popover
if (ev.palette !== "now-playing") return;
timer ??= setInterval(() => view.update(tree(position()), { palette: "now-playing" }), 1000);
}, "spotify");
view.onHidden((ev) => { if (ev.palette === "now-playing") { clearInterval(timer); timer = undefined; } }, "spotify");
view.onShown fires when a view level of yours comes on top of the
panel or the bar popover with its tree in (pushed, or uncovered when a
level over it pops, or the panel shown again with it kept), view.onHidden
when it leaves (popped, covered by a palette you pushed, the window
hidden). view.open() lists what is open right now, for a stream handler
that pushes into every open level (for (const ev of view.open()) ...).
A reload of the extension tells the new module about the levels already
open. dispose() still clears the interval: the old module stays
resident.
Pull: refresh and on. On a view palette, refresh: 5 (seconds,
manifest or code; the manifest wins) makes the app ask view(ctx) again
on that cadence while the level is on top, and on: ["media", "wake", "network", "show"] adds triggers (media: a track or player change from
the MediaRemote stream, wake from sleep, network back, show: the
panel shown with the level kept). Each answer replaces the tree in place
the same way. A tick while a pick is in flight is skipped (the reply
brings a newer tree). Nothing to write in the code: the palette's view
already answers from live state.
Which to use. Pull when the state lives elsewhere and a re-read is
cheap and rare (a dashboard every 30 s, a device list on wake): no
loop to own, nothing to stop. Push when you already have the change in
hand (a bridge's event stream, a 1 Hz clock you run anyway for the bar
item) or the cadence is faster than a request should be: the tree you
push is the one you would have answered. Both together is the usual
shape: push for the beat, refresh as the safety net (Spotify's lyrics
view pushes every second while the song plays, and refresh: 5 covers
a missed push).
Forms: asking for values
A pick can answer { form } instead of doing something, and pal pushes a
form level: the fields in place of the list, the form's title in
the search row, the submit in the footer. Enter submits (⌘Enter from a
textarea), Escape leaves, Tab moves between fields. The bundled Quicklinks
and Snippets create and edit their rows this way (quicklinks/,
snippets/ in pal-extensions).
pick: async (id, action, ctx) => {
if (action === "edit") return { form: {
id, // what the submit is addressed to; default: the row's id
title: "Edit Quicklink",
fields: [
{ kind: "text", id: "name", label: "Name", required: true, default: link.name },
{ kind: "text", id: "url", label: "URL", required: true, default: link.url, description: "{query} stands for what you type." },
{ kind: "checkbox", id: "pin", label: "Pinned", text: "Show at the top" },
],
submit: { id: "save", title: "Save" },
} };
if (action === "save") {
const { name, url } = ctx!.values!; // a string per field, a boolean per checkbox
if (!/^https?:/.test(String(url))) return { form: { ...editForm(id), errors: { url: "Not a URL" } } };
await store(id, name, url);
return { keep: true, toast: { title: "Saved" } };
}
},
- Fields:
text,textarea,password,select(withoptions),checkbox(withtextbeside the box); each hasid,label, and optionallyplaceholder,default,required,description(a help line under the field). Arequiredfield left empty (unticked, for a checkbox) blocks the submit in the panel: the field is marked and focused, and no pick goes out. - The submit is
pick(form.id ?? rowId, submit.id, ctx)withctx.valueskeyed by field id (ctx.argsare the level's, as for any pick). Answer it with{ form }again carryingerrors: { field: message }and the same form stays with the messages under the fields, what was typed kept; any other effect closes the form and runs as from a row:keeplists the palette again (the new row is there),toastshows over the list,copyor nothing hides.cancelnames the cancel button ("Cancel"). - The host refuses a form without fields, with two fields of one id, a
field of a kind the panel cannot draw, a submit id starting with
pal:, orerrorsfor a field that is not there.
Typed arguments: values in the search bar
A form is a page; a row that just needs a word or two before it runs
declares args instead, and the search bar takes them (Raycast's command
arguments). While the cursor rests on the row, one field per argument
appears after the query; Tab moves into them, Escape back to the query,
and Enter runs the row as it always does, with what was typed as
ctx.values by id. Nothing costs an extra keystroke: ssh marko with an
empty command is one Enter, ssh marko + Tab + uptime + Enter runs
that. A row of ssh that takes a command, a timer that takes a duration
and a name, a chat that takes a message for its Send.
{ id: "marko", name: "marko", subtitle: "cagdas@marko",
args: [{ id: "command", placeholder: "Command", required: true }, { id: "shell", placeholder: "Shell", kind: "select", options: [{ id: "zsh", title: "zsh" }, { id: "sh", title: "sh" }] }],
actions: [{ id: "connect", title: "Connect" }, { id: "copy", title: "Copy host", shortcut: "cmd+c" }, { id: "run", title: "Run there", shortcut: "cmd+r", args: true }] }
// pick("marko", "connect", { values: { command: "uptime", shell: "zsh" } })
- An argument has
id,placeholder(its label in the bar), and optionallykind(text,number,selectwithoptions,password, drawn masked),required(blocks the run while empty) anddefault. Values arrive as strings (select: the option id), like a form's. - Which actions take the values: those marked
args: true; when none is, the primary (the first listed, or the default pick). The rest run bare, so a chat row's Enter still opens the chat while itsSend message(args: true) takes the text. Arequiredargument left empty blocks only the actions that take it: the field is marked and focused, and nothing runs. An optional one left empty arrives as"". - A pick can still arrive without
values:pal run ext/palette/id?command=uptimefills them from the link's query, but a barepal run, an item hotkey or a pick from a script does not. Answer{ form }with the same fields then, or run with the defaults.
Links: routes of your own
Every row already has a deep link (pal://run/<ext>/<palette>/<id>,
Links), and "Copy deep link" in the panel writes it. A route
of your own is for what a row cannot say: an argument from the outside
(pal://timer/start?duration=25m&name=tea), a thing by name rather than
by id (pal://snippets/paste?name=sig), a keybind that should not go
through a row at all. Declare it in pal.json and answer it in the code:
"links": {
"start": {
"description": "Start a timer",
"params": {
"duration": { "description": "25m, 90s, 1h30m", "required": true },
"name": { "description": "Optional; the duration otherwise" },
"ring": { "description": "Ring the phone when it lands", "type": "boolean" }
}
}
}
export default defineExtension(manifest, {
palettes: { ... },
link: async (route, params) => {
if (route === "start") return { hud: await timer(String(params.duration), String(params.name ?? "")) };
},
});
pal://<name>/<route>?key=value (and pal call <name>/<route> key=value)
then reaches link(route, params). A route name is lowercase letters,
digits and -. A param has a description (the store and the settings
window show it), required, and a type: string (the default),
number, boolean (1, true, yes, on), json (a percent-encoded
JSON value) or string[] (a repeated key). The host checks the route is
declared, refuses a missing required param with a line in the HUD
(pal: timer/start: duration is required), coerces the rest and drops a
missing optional one, so params.name is undefined rather than "". A
key the manifest does not name rides through as a string.
The answer is an Effect like a pick's, run with the panel down: copy,
copy_files, open, paste, focus, layout, space, hud, toast (its title
is the HUD's line), push (the panel shows inside that palette, with the
args) or nothing. keep, show, view and form need the level a pick
came from and are refused with the reason, as effects.run refuses them;
to open a form from a link, point the link at the row that opens it
(pal://form/<ext>/<palette>/<id>?field=value, which also prefills it).
Throw for what cannot be done (no snippet "x"): the message is the
HUD's line.
A link from a web page asks first ("“Start a timer” from a link?
timer/start duration=25m"), unless the user trusts your extension in
general.deeplink_confirm; "confirm": true on a route keeps the card
whatever they set, for a route that does something irreversible
(system's run). The pal command never asks.
checkLinks(manifest, ext) runs on every load like checkPalettes: a
links block with no link function, a link function with no links
block (a route is reachable only when declared), a bad route name or a
param type not in the table is a load warning the settings window shows.
Your own tests can call it, and checkLinkParams(spec, raw, where) to
see what your handler would get.
Bar items: glanceable state on the bar
An extension can put an item on the bar (the macOS menu bar, sketchybar,
later a Linux bar): a glyph, a short title, a badge, a fill, and something
that opens on a click. The design is docs/design/bar.md; this is the
extension's side of it. One model, BarItem, rendered by the core on
every target; the extension never talks to a bar.
The model. render(ctx) answers the item's whole state and the core
diffs it against the last one:
hidden: the rule. An item earns its slot by having something to say;hidden: truetakes no space on any target, andrenderkeeps running so it can come back.empty: withhidden, the shape the user may keep on the strip anyway ({ icon?, title?, tooltip?, menu? }: the glyph, an honest tooltip such as "No unread mail", the same popover). The core's per-item[bar.items."ext/id"] show = "always"(docs/config.md) draws it muted, without badge or segments, in the hidden item's own frame (itsicon_size,icon_width,position);auto, the default, hides. The extension never reads the setting: it answers{ hidden: true, empty: { icon, tooltip, menu } }when it has nothing to say and leaves the choice to the core, so every item gets the setting without remembering it. Hidden withoutemptyhides under either setting: signed out, there is nothing to open a popover on. A setting that widens what the item computes (slack'sunread, media'srunning) stays the extension's; keeping the item up at all times is the core's.icon(a glyph from the bundled Nerd Font, an emoji,{ image },{ app }),title(short: 64 characters at most, the menu bar does not truncate),segments(up to 8 extra runs after the title, each{ id, icon?, text?, color?, tooltip? }; a click on one is thesegment:<id>action),badge(a count, or"dot"),color(the tag palette plustext,muted,accent,destructive),urgent(drawn as an alarm),stale(muted, "could not refresh"),progress(0..1, a thin fill),tooltip,refresh(seconds until the nextrender, this once).states: the facts the item knows, as{ name: scalar }, published as<extension>/<name>with the render (power/level,calendar/phase): what the manifest'srulesand anyone's state expressions read.nullwithdraws one. Not drawn.menu: what a click, the item's hotkey or a hover peek opens, always in pal's own popover. An array ofBarMenuNodeis a menu level: rows ({ type: "item", id, title, subtitle?, icon?, shortcut?, checked?, disabled?, style?, action? }),sections ({ title?, children }),submenus ({ title, icon?, children }, 3 deep at most),separators; 64 nodes at most.{ palette: "name", extension?, args? }opens that palette level, the panel machinery unchanged.{ view: View }draws the tree as a view level: the same level as in the panel (keys: "actions", the text field, the keyedmovetransitions), 420 px wide, the popover as tall as its content up to 720 px and the tree scrolling inside past that. Because it scrolls, a popover never omits rows: every row the item knows is in the tree, no row cap per section and no "and N more" line. A{ palette }naming a view palette opens as a view level too (view(ctx)asked withctx.compact). Without amenuthe click isonOpenand the extension answers an Effect.click: "open"takes that direct path even when the item has a menu (a hover peek still opens the menu);{ view },{ push }or{ show }in it opens the popover on that level.
A card of its own. The glance strip (general.glance,
docs/config.md) draws chosen items as cards over the empty root: the
item's last render, its title or count large, its tooltip's first line
under it. An { image } icon is drawn as a cover beside the text,
progress runs along the card's foot, and glance: { lines } (at most
3, never drawn on a strip) adds lines under the text, each a way in of
its own: a click on a line runs its action through onAction, the
Effect opened the way a click on an item without a menu opens one (a
push lands in the popover). An item whose manifest says "strip": false lives only there: no strip draws it unless the user gives it a
target in Settings › Bar, it renders (and polls) while a card shows it
and not otherwise, and the glance picker names it by its own title.
Media's playing is one: whatever plays, the newest first, a second
playing one as { text: "also: Weird Fishes · Spotify", action: "open:spotify" }, the lead's own view ({ palette, extension }) as the
item's menu so the card's click and ⌥N open it.
The manifest. bar.<id> next to palettes, so the settings window
lists the item without running the code:
"bar": {
"notifications": { "title": "Notifications", "description": "Unread count", "refresh": { "every": 300, "on": ["show", "wake", "network"] } }
}
Settings. settings next to refresh: the item's own, the same
SettingSpec list an extension declares, shown on the item's pane under
Settings › Bar and kept in [bar.items."<ext>/<id>".settings]
(docs/config.md). Every render, onAction, onOpen and onShown gets
them resolved as ctx.settings: the declared defaults under what the file
sets, an instance's item (gmail@work/unread) over the default
instance's. A change renders the item again. What only the item reads
belongs here (a threshold, a label, whether to show artwork); what the
palettes read too stays an extension setting. Code that runs without a
ctx (a ticker's bar.update) keeps the last ctx.settings it saw.
"settings": [
{ "kind": "select", "id": "label", "label": "Label", "default": "percent", "options": [{ "id": "percent", "title": "Percent" }, { "id": "none", "title": "None" }] }
]
Rules. rules next to refresh: how the item draws by its facts,
decided by the core at draw time and overridden by the user by id
(docs/config.md, "Rules"). An item states what it knows in states and
leaves presence, urgency and colour to its rules; the settings that used
to pick a threshold or a colour go with that:
"rules": [
{ "id": "fine", "when": "not power.charging and power.level >= 50 and not power.alert", "description": "Plenty left, nothing wrong.", "hidden": true },
{ "id": "low", "when": "not power.charging and power.level <= 20", "description": "Getting low.", "color": "amber" },
{ "id": "critical", "when": "not power.charging and power.level <= 10", "description": "Nearly out.", "color": "red" }
]
Each rule: id (unique in the item, lowercase letters, digits, _),
when (a Jinja state expression; the item's own facts are
<extension>.<name>), a description for the Settings pane, and what it
does while it holds: hidden, urgent, position, and the [bar.items]
appearance keys (color, size, icon, show_title, dim, font,
width, ...). In order, later wins. A rule that does nothing is a load
warning. A hidden rule takes the same path as hidden: true from the
render: show = "always" keeps the empty shape, so a render that may be
hidden by a rule answers empty too. The render keeps running while a
rule hides the item (its facts come from the render); the user's
show_when/hide_when are what hold an item off without one.
refresh.every is seconds between renders (10 at least), on adds
triggers: show (the panel shown), wake, network (back online),
focus (the front app changed), minute, state:<name> (that state's
value changed; state:* any state, or a hold or reset by hand: below,
"States"). The core renders every item
once at load and whenever the extension's settings change. The user's
[bar.items] show_when/hide_when (docs/config.md) holds an item off
the strip by a state expression without a render; the flip back renders
once with reason state. keys, as on
a palette ([{ "keys": "space", "title": "Pause" }]), is the key table
of the item's own { view } popover for the store and the settings
window.
The code. bar.<id> in the default export, next to palettes:
export default defineExtension({
palettes: { /* ... */ },
bar: {
notifications: {
render: async (ctx) => { // ctx.reason: load | every | show | wake | network | focus | minute | settings | update | cli | open
const n = await notifications(); // the palette's own loader, one cache
if (n.length === 0) return { hidden: true, empty: { icon: "\u{f09b}", tooltip: "No unread notifications", menu: [] } }; // what `show = "always"` keeps
return { icon: "\u{f09b}", badge: n.length, menu: [
{ type: "section", title: "Unread", children: n.slice(0, 5).map((x) => ({ type: "item", id: x.id, title: x.title, subtitle: x.repo })) },
{ type: "separator" },
{ type: "item", id: "read-all", title: "Mark all read", shortcut: "cmd+shift+r" },
] };
},
onAction: async (action, ctx) => { // a menu row was picked (its `action`, default its `id`); `segment:<id>` for a segment
if (action === "read-all") { await markAllRead(); return { keep: true, hud: "Marked read" }; }
return { open: urlOf(action) };
},
onOpen: async (ctx) => ({ copy: code }), // no `menu`, or `click: "open"`
onShown: async (ctx) => { /* the popover opened (a peek counts): warm a cache */ },
},
},
});
render(ctx)returns aBarItem, sync or async.ctx.reasonsays why (the manifest's timer isevery; a push asking for a render isupdate; a click that opened the popover isopen),ctx.anchorwhere a click came from (menubar,sketchybar,hotkey,cli),ctx.compactthat what the call answers is drawn in the popover (set on every bar call today: 420 px wide, so a{ view }lays out for that width; aview(ctx)orpickreached from the popover getsctx.compacton itsCtxthe same way),ctx.settingsthe item's own settings as they apply (above). The host checks every answer (checkBarItem, the limits above, nopal:action ids, a{ view }menu throughcheckView); over the limits is an error the core marks the itemstalewith.onAction(action, ctx)andonOpen(ctx)answer anEffectlikepick:open,copy,hud,push(drill into a palette in the popover),view,form,keep(re-render the item, the popover stays).settings.get()works inside all of them without an argument. From a{ view }popover every action isonActionwith the action's id: a key of the view, Enter on the first listed action, a click on a node carryingaction. What a control read rides inctx.values: the view's text field on Enter (input), a form's fields by id, a slider's clicked fraction (value). Amultiaction run over the view's marked rows (mark) has them inctx.ids; without marks it is absent and the action means the cursor's row.bar.update(id, item)in@zcag/palpushes an item from the extension's own side, for a webhook, a file watcher or a poll it runs itself: the core draws it as ifrenderhad answered.bar.refresh(id)asks for arenderwith reasonupdate. From a timer or a watcher (outsiderender/list/pick) pass the extension's name as the last argument, as forstorage. A push is checked like a render answer.- A live popover. A
{ view }level in the popover is a view level of the item's own:view.update(tree, { bar: "<item id>" })replaces its tree in place (the strip untouched;bar.updatere-renders the whole item, popover included, and is the call when the strip changes too), andview.onShown/view.onHiddenfire for it with{ bar }andcompact: truewhen the popover opens on it (a peek counts) and when it closes: the "popover closed" signalonShownnever had. A{ palette }level naming a view palette is that palette's level, with{ palette }in the notifications andcompact: true. See "Live views" above. - An extension that runs an interval or a watcher for its pushes declares
dispose()on the default export: the host calls it before the extension is reloaded or removed, since the old module stays resident and would keep pushing otherwise.
The bundled items, each in the extension that already owns the
data, so the strip and the palette share one loader and one cache. Every
popover is a { view } level laid out for 420 px (<name>/view.ts in pal-extensions,
Hue's popover.ts: a pure render(state) the gallery draws too), with
keys: "actions", a keycap hint row, the cursor as a selected ring the
arrows move and a click sets, and the strip untouched:
- Hue,
home(hue/): the main room's colour as a dot and the count on the strip. The popover: a status row (lights on, the motion and temperature sensors as badges, a bridge that is away), the rooms as a grid of tiles in their lit colour with a switch, the count and a thin brightness bar (a tap toggles, the chevron opens the room's lights inline, each with a slider a tap sets), the scenes as five-swatch tiles on the digits,eeverything on,xall off. Follows the event stream (abar.updateat most every 300 ms redraws strip and popover). - Spotify,
playing(spotify/): the cover, the titles, a progress row ticking every second while shown, the lyric line playing with its neighbours, the transport and state keycaps, the queue's next two as rows (a click skips to one). - Timer,
timer(timer/): a card per timer with the time left large and a progress bar in the strip's colour,spacepause/resume/dismiss,+five minutes,backspacestop,na text field (25m tea) whose Enter starts one (ctx.values.input), the last durations as tiles. Ticks every second while shown. - Calendar,
upcoming(calendar/): today's events as rows (a time column, the calendar's colour bar, "in 12 min" / "ends in 24 min", the running one on a card), a Join tile on rows with a call, all-day events as badges, tomorrow folded under a header (t),othe calendar, a 30 s tick from the cache while shown. - Slack,
unreads(slack/): a section per kind (direct messages, mentions, threads) with avatars fetched into data urls, the latest line and a count badge; Enter opens,ra reply field whose Enter postsctx.values.input,mmarks read,aall read; quiet channels as badges. Urgent while a direct message waits. - GitHub,
notifications(github/): the unread threads grouped by repository with a colour rail per subject type, a reason badge and the age; Enter /omarks read and opens,mmarks read,aall read,pthe palette; a height budget keeps six or so rows and says how many more are in pal. Hidden at zero and signed out. - Now Playing,
now-playing(media/): the cover large (the stream's picture, a cover url fetched once, else the app's icon), the titles, a progress row ticking while shown,space/ arrows /c/okeycaps. Hidden while nothing plays. - Verification Codes,
latest-code(otp/): the newest code as digit tiles with the sender and a bar counting the minute down, Enter copies (concealed),ppastes, the two before as rows a click copies. The item leaves when the minute ends. - States,
forced(states/): the states held by hand with the time left on the soonest to expire; hidden while none is.on: ["state:*"], so a hold or a reset redraws it at once. - Stats,
cpu,memory,disk,network,load(stats/): five items off one 3 s sampler that pushes them all (bar.update, the core'severyfloor being 10 s), each stating its facts (stats/cpu,stats/memory_pressure,stats/disk_free,stats/net_down, ...) and hidden by its manifest rules while quiet. The popovers: per-core bars and the busiest processes with a Kill key, the memory segments and the largest, a card per volume (Enterreveals), every interface with its rates and address, the load tiles, each with a sparkline of the last 60 samples as an SVGimagein the theme's ink.psruns every tick only while a process popover is open. - System,
awake(system/): a coffee and what is left of a keep-awake run (∞without an end), hidden while off; the popover a card with the time left and a bar, the presets as tiles on the digits, the display switch,ua field for45mor14:30, Enter allows sleep. Ticks at the moments the countdown's text changes. The run iscaffeinatewith its own-t, found back by pid after a host restart.
States
state in @zcag/pal reads and feeds the named variables of [states]
(docs/config.md, docs/design/states.md): what the user declared,
the built-ins (hour, weekday, front_app, network, theme,
locked, idle, panel, ...), and what other extensions published.
state.get(name): the resolved value (a JSON scalar;nullunknown or no such state);state.get()every state by name.state.set(name, value): publish<me>/<name>(sessions/workingfromsessions; the instance key for an instance of amultiextension).nullwithdraws it. An extension feeds only its own prefix; the user composes it into a state of theirs with an expression (sessions.working > 0). Declare what you publish understatesinpal.json. From a timer or a watcher pass the extension's name as the last argument, as forstorage.state.onChange(name, cb):cb(value)when that state's resolved value changes;state.onChange(cb):cb({ name: value })on every change. Returns the unsubscribe. Only a resolved value moving fires it; a hold at the value a state already had does not.state.list(): every state as the palette lists it (StateEntry: value, source,until,expr,description,error,declared,builtin);state.eval(expr): what a Jinja expression reads now.
A bar item that follows a state re-renders on state:<name> in its
refresh.on; the user's show_when/hide_when on the item hides it
without a render. The bundled sessions publishes working and
waiting (its counts) at every render.
Controls: one device's view drives another's
An Apple TV and the TV it is plugged into are used together: the remote
you navigate with should set the TV's volume, switch its inputs and its
sound output, and wake both. Neither extension knows the other
(docs/design/controls.md). pal defines five controls; an extension that provides one says so
and publishes its state, any extension asks for "my volume", and the
user's group ([groups], Settings › Groups) decides who serves it.
Without a group every extension is served by itself, so a provider that
is also a consumer (the usual case) behaves as it did alone.
| Control | State (every field optional) | Ops |
|---|---|---|
volume |
level 0..1 (absent: the device only steps), muted |
set(level), step(1 | -1), mute(on) |
power |
on, busy (waking or going to sleep) |
set(on) |
inputs |
list: { id, name, icon? }[], current |
set(id) |
outputs |
where the sound plays: list: { id, name, icon? }[] (a TV's speakers, a soundbar, Q-Symphony), current, reconnect (true: the provider can move the sound away and back, what wakes a soundbar gone quiet) |
set(id), reconnect() |
player |
state (playing/paused/stopped), title, artist, album, artwork (a url or data:), app, position (s) with at (unix ms it was read), duration, palette (your own Now Playing palette), item (your own bar item showing it), now (true: you have your own row in the root's Now section for it), same (bundle ids of the system's players showing the same playback) |
play_pause, next, previous, seek(seconds) |
Every state also takes device, the name of the thing it controls
("75" Neo QLED"): a part drawn for another device says whose it is.
Providing. Declare the controls in pal.json ("controls": ["volume", "power", "inputs"]; Settings offers the extension for them
without loading it), handle the ops in the default export, publish the
state whenever it changes, null when the device cannot be driven:
export default defineExtension(manifest, {
palettes: { ... },
controls: {
volume: { set: (l) => tv.setVolume(l), step: (d) => tv.key(d > 0 ? "VOLUP" : "VOLDOWN"), mute: (on) => tv.mute(on) },
power: { set: (on) => (on ? tv.wake() : tv.key("POWER")) },
inputs: { set: (id) => tv.source(id) },
},
});
await controls.publish("volume", { device: tv.name, level: 0.24, muted: false });
A handler runs in your own context (your settings, storage, view.update
are yours), whoever asked; what it answers is ignored and a throw is the
asker's error. The host warns when the manifest and the code disagree
(checkControls).
Asking. From any extension, the provider included:
controls.get(control): the state as it is served to you, withprovider: { key, device };nullwhen nobody serves it.powerin a group answers every member's (onwhen any is,memberseach).controls.run(control, op, ...args): on whoever serves it;powerin a group goes to every member at once. Waits up to 15 s (CONTROL_RUN_MS): a wake crosses the network.controls.onChange((c) => ...):{ control, provider, mine }on every publish and every regroup (providerempty then);minewhen your owngetanswer moved. Redraw onmine; Now Playing listens to everyplayer.controls.all(control): every provider's state, grouped or not. Aplayernaming its own bar item (item: "playing") is answered withitem_shown, whether that item is on a strip now (enabled, aimed somewhere, not held off, its last render not hidden):media's item skips such a player, so a playback is never on the bar twice and a bespoke item (Spotify's lyric line) is never replaced.media's Now row lists every playing player, published ones first, except one that saysnow: true(its own suggestion shows it): a device needs nosuggestof its own to be in the root's Now section.controls.runOn(key, control, op, ...args): one op on the providerkeyitself, past any group: a playeralllisted (media's Now Playing runs each row's own transport).controls.publish(control, state | null).
The parts a group can hand over are pal's, so they look the same
whoever serves them and a part nobody serves is not drawn: volumeRow(v, width, { device }) (the glyph mutes, a slider sets, the percentage; the
step buttons when there is no level; the serving device's name under it
when it is not device, your own), volumeButton(v, 1 | -1, size, { lit, label }), powerButton(p, size, { lit, labels }) (wake when off, sleep
when on), inputsRow(i, width) (a chip each, the current one lit),
outputsRow(o, width) (the same for where the sound plays, with a
reconnect chip when the provider offers reconnect; protocol 9), and
controlButton under them for a button of your own in the same look
(lit is a key's flash, CONTROL_LIT). Each takes what controls.get
answered. Their clicks are actions spelled
controls:<control>:<op>[:<arg>] (controls:volume:step:-1,
controls:power:set:true, controls:inputs:set:<id>,
controls:outputs:set:<id>, controls:outputs:reconnect;
controls:volume:set and controls:player:seek read a slider's
values.value, controls:volume:mute and controls:power:set without an
argument toggle). withControls(view) declares the ones the tree runs as
hidden actions, so the view needs no list of its own; give your keys the
same ids to route them too ({ id: "controls:volume:step:1", shortcut: "=" }). In pick, one line forwards them:
pick: async (_id, action, ctx) => {
if (await controls.act(action ?? "", ctx)) return { keep: true };
// ...your own actions
},
Redraw on controls.onChange with mine: the TV publishing its new
volume is what moves the slider in the Apple TV's remote.
Storage
storage in @zcag/pal is a small per-extension key-value store:
get(key) (null when unset), set(key, value) (any JSON; null removes),
remove(key), keys(). The core keeps one file per extension (per
instance of a multi one: gmail@work.json),
<data dir>/pal/storage/<extension>.json (~/Library/Application Support/pal/storage/ on macOS, ~/.local/share/pal/storage/ on Linux),
written whole and atomically on every change and shared by every config
profile. It is capped at 256 KB serialised: a set that would cross the
cap rejects and nothing is written. For a bankroll, a cursor, a last-used
choice; not for a cache. Which extension is asking is known inside
list/pick/view and at import time; elsewhere pass the name as the
last argument.
Syncing storage
A user signed in to a pal account (Settings › Account) gets their
settings and the storage of the extensions that opt in on every machine
and on play.cagdas.io. An extension opts in with sync in pal.json,
one rule per storage key:
"sync": {
"best": "max", "unlocks": "union", "plays": "sum",
"stats": { "fields": { "wins": "sum", "streak": "latest", "longest": "max" } },
"kills": { "each": "sum" },
"settings": "latest", "hand": "local"
}
Two devices that both played since they last synced each have a value for the key; the server merges them by the rule, so neither replaces the other:
| rule | merge |
|---|---|
max, min |
the larger, the smaller (numbers) |
union |
arrays: every element of both, first seen first, no duplicates |
sum |
counters: the other device's count plus what this one added since it last synced |
{ "fields": {...} } |
objects: field by field, each by its rule (recursively), unlisted fields latest |
{ "each": rule } |
objects keyed by something open-ended (an enemy, a level, a card): every field by the one rule; beside fields, the rule for the fields it does not list (protocol 6) |
latest |
the one written last; the other is kept in the account's history |
local |
never synced: stays on this machine |
- Every key that holds progress needs a rule. A key not listed syncs
as
latest, which for progress is a loss the player feels: yesterday's longer streak replaced by today's shorter one, the levels one machine opened gone on the other. A best ismax(minfor a time), what was opened or collected isunion, a count issum, a stats object isfields, and stats per enemy or per level areeach. localfor what belongs to one sitting on one machine: a hand of cards in progress, a cursor. A credential never goes in synced storage: withoutsyncnothing leaves the machine, which is why it is opt-in.- A first sign-in on a machine that already has progress merges it into the account by the same rules, as does a machine back from a month offline.
- A value that arrived while the extension runs (merged, or from another
device) is told to it:
storage.onChange((key, value) => ...)in the SDK,pal.storage.onChange(fn)in a game's page. Code that keeps state in memory takes it there, so its nextsetdoes not write back something older (thatsetis merged again in any case, so nothing is lost, but the game would show stale numbers until then). - pal-pack refuses a package whose rules are not these, and the host warns on load.
Leaderboards
A game declares its boards in pal.json, as many as it has modes, maps
or daily runs:
"leaderboards": [
{ "id": "daily", "title": "Daily", "order": "desc", "format": "points", "period": "day" },
{ "id": "stage/*", "title": "Stage {1}", "order": "asc", "format": "time", "min": 0, "max": 3600 },
{ "id": "endless", "title": "Endless", "order": "desc", "format": "time" }
]
id:[a-z0-9_-]segments joined by/. A*segment matches one segment of a posted board (stage/hyper-3), and{1}in the title is what it matched ({2}the second*).order:desc(higher is better) orasc(lower is: a time to clear).format:points,time(seconds, shown1:01.20) ormoves.period:all(the default),dayorweek; days are UTC, so a daily board is the same board everywhere.min,max: a value outside is refused, the one cheap check against a forged score.
import { account, leaderboard } from "@zcag/pal";
const { best, rank, total } = await leaderboard.post("stage/3", 74.2);
const board = await leaderboard.get("daily", { anon: false }); // { board, rows, me }
if (!(await account.get()).signedIn) { /* "Sign in to keep your scores" */ await account.signIn(); }
leaderboard.post(board, value)keeps the player's best on the board (and period) and answers it with its rank and the board's size. Signed in, the score is the account's; signed out, this device's, under an anonymous id of its own (never the usage id), shown as a generated name (Teal Fox) and moved to the account when the user signs in. Offline it is queued and sent later, answering{ queued: true, best }with the local best. Signed in without a handle yet, it rejects with "choose a handle" and the app opens Settings › Account at the handle.leaderboard.post(board, value, { replay })(protocol 7) sends the game's own record of how the score was made, a string of at most 32 KiB pal never reads (Highway's: the inputs of a run, which drive it again). The board keeps one per player, the one that came with their all-time best; a slower run's is dropped. A queued score keeps its replay.leaderboard.get(board, { period, anon }): the top 50 (rows:rank,name,anon,value,at,me, andreplaywhen that score's replay is kept), the player's own row asmewhen they are not among them, and the board's declaration.anon: falsehides anonymous players and ranks within what is shown.leaderboard.replay(board, row.replay)(protocol 7): that replay,{ board, value, name, anon, at, data },dataas the game sent it.account.get():{ signedIn, handle }, never the email.account.signIn()opens Settings › Account (the sign-in sheet on play.cagdas.io).- A game's page:
pal.score(with{ replay }),pal.leaderboard,pal.replay,pal.account,pal.signIn, the same calls ("Game surfaces"). An app older than protocol 7 has nopal.replayand drops a score's replay: a page checkspal.replaybefore offering one. - No board UI is needed: the Games shelf and the game's page on
play.cagdas.io show its boards. A game may draw its own from
get. - Boards exist for pal's own registry's extensions: the server reads the
declaration from the newest build it has and refuses an undeclared
board. pal-pack refuses a package whose
leaderboardsis malformed.
The @zcag/pal package
import { ... } from "@zcag/pal" is pal's extension API: the calls into the
core and the types of everything above. It lives in sdk/ in the repo and is
the package of that name on npm (not published as of this writing; "Writing one"
below links it from a checkout). The host links it into
<root>/node_modules/@zcag/pal in the store and in every extension_dirs root,
so the name resolves for an extension there without a fetch (the host runs Bun
with --no-install). An extension that carries its own copy in node_modules
(a bun add) gets that one instead, which works the same: the package reaches
the host through a process-wide slot, not a shared module, so the version in
your node_modules only has to speak the same wire. Every call is one request
to the core.
controls.{publish, get, run, all, onChange, act}and the partsvolumeRow,volumeButton,powerButton,inputsRow,controlButton,withControls(protocol 4), theoutputscontrol andoutputsRow(protocol 9): "Controls" above.settings.get<T>(): the extension's values,[extensions.<name>](the instance's, for an instance of amultiextension: "Instances" above).settings.palette<T>(): the current palette's declared values.settings.onChange(cb): called with new values.settings.set(id, value)/settings.set({ id: value, ... })writes declared extension-level settings to the config file the way the settings window does (a surgical edit through the core: comments and the rest of the file stay);settings.setPalette(id, value)(or the object form) the current palette's. The core checks each value against the setting'skind(aselectagainst its options, anumberagainstmin/max, alistas strings), puts asecretin the OS keychain and writes thekeychain:pal/<name>-<id>reference so the value never sits in the file, unsets the key fornullor the manifest's default, and writes the ids of one object in one edit so no reader sees half of them. The promise resolves once the file is written, withsettings.get()already answering the new values;onChangefires once per change. An id the manifest does not declare, or a value of the wrong kind, rejects and nothing is written. What Hue does with the application key after pairing, so it lands in the keychain and under Settings › Extensions › Hue like a typed one. A hint row that asks the user to set one opens Settings on that row:{ open: "pal://settings/extensions?anchor=extensions:<key>:<id>" }(instance().keyfor<key>, so a second account lands on its own table; Links), the row lit as a search hit is.instance(): which instance of the extension this code runs as,{ key, name, title, isDefault }("Instances" above); the bare name askeyandisDefault: truefor an extension withoutmulti.clipboard.list({ query, kind, limit, offset }),get(id),current()(the newest entry, or null),pin(id, pinned = true),rename(id, name)(a name that titles the entry and is searched like its text,ClipboardEntry.name;nullor blank clears it),delete(id),clear(),copy(id)(back onto the clipboard),imageUrl(id, size)for an image entry.thumbnailUrl(path, size): an image file on disk as the webview loads it (icon://localhost/file): a thumbnail fitted intosizepx (256 at most: a larger size is a 404), or the file itself for 0 (a PNG or a JPEG). PNG, JPEG and GIF by extension, absolute paths only; anything else is a 404 and the row keeps its glyph. A screenshot's row, a browsed folder's pictures.windows.list()(every window, most recently used first on macOS and Hyprland,Window:id,app,title,bundle_or_class,pid,minimized,hidden(the app is hidden, macOS),on_screen,monitor,workspace,icon),focused()(the window with keyboard focus, the app behind the panel),activate(id)(the window's app to the front, unhidden, the panel hiding as it comes up; its name),close(id),minimize(id),frame(id)(aRect:x,y,w,h),setFrame(id, rect),displays()(Display[]:id,frame,visible_frame,primary),layout(req)(aWindowLayoutRequestrun now, the panel up; frompickprefer thelayouteffect),spaces()(every Space, workspace or desktop in the desktop's order,Space:id,index(the number the desktop shows),name(Hyprland's or Sway's),current,previous(left most recently),fullscreen,monitor,windows(ids fromlist())),goSpace(id)(the space in front now; frompickprefer thespaceeffect, which hides first). Focus is the{ focus: id }effect frompick, so the panel hides first.system.commands()(sleep, lock, dark mode, volume, and so on, withavailableper machine),system.run(id)(hides the panel, then runs).apps.forFile(path): the applications the OS registers for a file (App[]:name,path(the.appor.desktop, usable asicon: { app: path }),bundle_id?,default), the default first; Launch Services on macOS,xdg-mime+mimeapps.list+mimeinfo.cacheon Linux.apps.openWith(path, app)opens the file with one of them.storage.get(key),set(key, value),remove(key),keys(): the extension's own key-value file (above);storage.onChange(cb)(protocol 5): a value sync brought in ("Syncing storage").leaderboard.post(board, value),leaderboard.get(board, { period, anon }),account.get(),account.signIn()(protocol 5): "Leaderboards" above.color.sample(): one pixel off the screen, picked by the user with the OS's own loupe (NSColorSampleron macOS, no permission; theorg.freedesktop.portal.Screenshot.PickColorportal on Linux, which may ask once), as{ r, g, b, hex }in sRGB, or null on Escape. The panel hides first and stays hidden. The user may take a while, so the call waits up to two minutes (SAMPLE_TIMEOUT_MS), longer than a pick may: frompickstart it, return at once, and finish in the background witheffects.run.effects.run(effect): anEffectfrom outside a pick, for work that finished after the pick returned. The OS effects (copywith "Copied" or thehudtext in the HUD,copy_files,open,paste,focus,layout,hud) andpush, which shows the panel inside that palette (itsvieworlistasked afresh, as a palette hotkey would).toast,keep,view,formandshowneed the level a pick came from and are refused.extensions.list(): every extension the host found, as{ name, version, root, loaded, store, bundled }:storefor one in the user store (a registry or source install),bundledfor one that ships with pal.- The store (capability
store; what the Store and Games palettes are built on). Nothing is compared on your side: whether an extension has an update is the core's one check.extensions.state(): aStoreState, cached by the core and cheap:registries(each withlast_checked,last_ok,last_error,channel,auto_update,count),statuses(oneStoreStatusper installed, bundled or local extension:originbundled,storeorlocal, theregistryit updates from, theinstalledbuild, and astate:up_to_date,updatewith the buildto,needs_newer_pal,yankedwith itsreplacement,no_longer_listedwithwhy,unchecked,sourceorlocal),available,pendinginstalls,rolled_backupdates,unlistedstore folders,disablednames,leftoversand the namesbusyright now.extensions.available(): everyAvailableExtensionthe registries list, installed or not:name,registry,listing(title, tagline, description, category, keywords, icon, author, platforms, palettes, screenshots, requires, suggests),installed,bundled,installable(a build runs here and no other source has the name),blocked(why not, in words) and thebuildan install would get.extensions.refresh(): fetches every registry now, then answers the newStoreState. Slow: stream the cached rows first ("Slow listings").extensions.install(name, { registry?, from? }),extensions.update(names?)(every update when none are named) andextensions.remove(name, { forget? })do the work and resolve when it is done and the extension loaded (or gone), with aStoreResult{ name, ok, loaded?, error? }each: a failure isok: falsewith the reason, one that installed but failed to loadloaded: false.fromsays where it was asked for, for the usage counts (Usage).forgetalso deletes its settings, storage, cache, ranking and keychain secrets. No card is shown: ask throughAction.confirmfirst.
selection.text(): the text selected in the app in front, or null. The accessibility API first (AXSelectedTextof the focused element on macOS, the primary selection on Linux); when that answers nothing andgeneral.selection_snapshotallows (the default), the copy shortcut is sent and the clipboard read and put back as it was, with pal's own history looking away. Rejects on macOS without Accessibility (the prompt is shown once per run). Reading it frompickworks: the panel does not take the selection from the app behind it. The Snippets palette fills{selection}with it.selection.files(): the files selected in the file manager in front, as absolute paths in its order (string[], never rejects). macOS: Finder's marked items, the front window's or the Desktop's, overosascript, and only while Finder is the app in front; a folder shown with nothing marked is nothing, not the folder. Empty otherwise, and on Linux, where no file manager exposes its selection portably. Read once per panel show and cached likedialog.current()(~190 ms on hornet, 1 ms when Finder is not in front), so asuggest, a listing on every keystroke and apickinside the palette share one read and all see what was marked when the panel came up. The Files palette's "Selected in Finder" section, System's Quick Look row and the Images palette's inputs read it;{files}in a snippet or a quicklink fills it in.textAtHand(): what a palette opened with nothing typed should work on: the selection (a failed read counts as none), else the clipboard's text (clipboard.current(), or the newest text entry), trimmed, as{ text, where: "selection" | "clipboard" }or null; one read perTEXT_AT_HAND_TTL_MS(2 s) across calls, so aninputpalette may ask on every empty listing. Translate and Turkish start from it.dialog.current(): the open or save panel in front, or null (thedialogeffect types a path into it).permissions.status()(Permissions:accessibility,calendar,full_disk_access,input_monitoring,location; a boolean or agranted/denied/not_determined/restricted/unavailablestate) andpermissions.request(which): the system prompt while the OS still has one to show, else the System Settings pane. Honoured only while the user is looking at your extension: inside one of its palettes (a listing at startup, a relist on a show or a background refresh never prompts; the ask is skipped and logged,permissions<TAB>location<TAB> skipped), or from apick(Enter on a row that offers it, wherever the panel is). Ask lazily, from the listing that needs it, only whilenot_determined, and give the palette a hint row whose pick asks too, so a user who lands in the palette after the held-back listing has a way in: the prompt is modal. The hint row says what the permission is for in pal's words ("Wi-Fi names need Location access: macOS shows them only to an app with it"), since the system prompt says little and pal asks nothing at launch. What the Wi-Fi palette does forlocation.calendar.permission(),request()(the Calendars prompt on macOS),openSettings(),calendars()(Calendar[]:id,title,color,source,writable),events(from, to, calendars?)(unix ms;CalendarEvent[]withstart,end,all_day,location,notes,url,calendar,attendees,organizer,conference_url,recurring,my_status, andoccurrenceon a recurring one),create(event)(aNewCalendarEvent; resolves with the id),delete(id, occurrence?),open(id, occurrence?). EventKit on macOS,khalon Linux (no delete or open there). What the Calendar extension is built on.ocr.image({ path })orocr.image({ data })(base64 bytes): the text in an image, lines top to bottom, an empty string for none; a PDF is its first page (pdftoppmwhen installed, elsesipson macOS). The Vision framework on macOS (accurate level, language detected),tesseracton Linux when installed;ocr.available()says, andimagerejects with "OCR unavailable" otherwise. Waits up to 30 s (OCR_TIMEOUT_MS). The Clipboard History and Files palettes offer it on images.conceal(text, clearAfter?): theCopyTextfor a secret (above);CONCEAL_SECONDS(30) is the default clear.expand(text, sources): the placeholder grammar every text pal fills in shares (sdk/src/placeholders.ts):{clipboard},{selection},{files}(the Finder selection's paths one per line, or joined bysep=),{date},{time},{datetime}(each withformat=over the tokensYYYY YY MM DD HH mm ss ddd MMMandoffset=+1d/-2w/+3h/-90m),{uuid},{cursor}(dropped),{snippet name=...}(one level deep); anything else in braces stays.sources(PlaceholderSources):clipboard()(read once, only when asked),selection?()(the clipboard when null or throwing),files?()(empty when throwing),now?(),uuid?(),snippet?(name); each optional one absent leaves its placeholder as written.hasPlaceholders(text),formatDate(d, format),offsetDate(d, offset),isoDate(d),isoTime(d),FORMAT_TOKENS,PLACEHOLDERScome with it. Snippets pastes with it, Quicklinks fills a url with it (the values percent-encoded through the sources), Obsidian appends with it.home(path): a leading~expanded.core.call(method, params): the raw bridge.xdg(name): a freedesktop icon name as the glyph the app draws it with (icon: xdg("dialog-error")), undefined for a name it does not know.bar.update(id, item): push a bar item now (above, "Bar items");bar.refresh(id): ask for a render.state.get(name?),state.set(name, value),state.onChange(name?, cb),state.list(),state.eval(expr): the states (above, "States").view.update(spec, { palette?, bar?, id? }): push a tree into an open view level (above, "Live views");view.onShown(cb)/view.onHidden(cb): a level of yours came on top or left;view.open(): the levels open now;VIEW_UPDATE_MIN_MS(33): the coalescing window.surface.post(msg, { palette? }): a message for the open game surface's page (above, "Surfaces");SurfaceKitis the type of the page'swindow.pal,isSurfaceSrc(src)whatcheckViewaccepts as asurfacenode'ssrc.checkView(view),checkForm(form),checkBarItem(item),checkEffect(effect),checkIcon(icon): what the host runs on every answer (the limits above:MAX_NODES,MAX_DEPTH,MAX_BAR_TITLE,MAX_BAR_SEGMENTS,MAX_BAR_MENU_NODES,MAX_BAR_SUBMENU_DEPTH,MAX_TILE_SVG,MAX_BADGE;SHELL_PREFIXis the reservedpal:), for an extension's own tests;shortcutsOf(action): an action's keys as a list.checkPalettes(manifest, ext): the manifest against the code ("Where a palette is described"),{ metas, warnings };kindOf(p): the kind a palette implies (PALETTE_KINDSlists them,isViewPalette(p)tests one);paletteMeta(name, p, manifest): what the host announces for a palette;instanceTitle(title, instance)andstripInstance(title): the{instance}rule above.checkLinks,checkLinkParams,checkLinkEffectand the tablesLINK_PARAM_TYPES,LINK_EFFECT_REFUSEDare the links' ("Links: routes of your own");VIEW_TRIGGERStheonnames.defineExtension(ext)anddefineExtension(manifest, ext): the typed default export.storage.LIMITis the 256 KB cap;XDG_ICONSthe tablexdgreads.audio.devices()(every output and input,AudioDevice[]:id,name,kind,default,volume,muted,transport),setDefault(id, kind),setVolume(id, kind, percent),setMute(id, kind, muted?)(omitted toggles; resolves with the state after). CoreAudio on macOS,wpctlelsepactlon Linux.bluetooth.devices()(paired,BluetoothDevice[]:address,name,connected,kind,battery,battery_detail; connected first),connect(address),disconnect(address)(synchronous, seconds).system_profiler+ IOBluetooth on macOS,bluetoothctlon Linux.privacy.inUse()(what uses a sensor now,PrivacyUse[]:sensorcamera/microphone/screen,app,process,pid,path,device; cameras first). macOS: the camera and the screen from Control Center'ssensor-indicatorslog (what drives the menu bar's dots, app named), the microphone per app from CoreAudio;pactland/dev/video*on Linux. The first call starts a watcher: a bar item withprivacyinrefresh.onre-renders on every change.wifi.status()(interface,powered,currentwithssid,signal,channel,security,ip;ssidnull when macOS hides it),known(),scan(mode)(cachednever runs the tool,autoreuses a scan under a minute old,freshscans now;networksstrongest first plushidden, the count of nameless ones),join(ssid, password?),forget(ssid),password(ssid)(the keychain prompts on macOS),setPower(on). CoreWLAN in-process,ipconfigandnetworksetupon macOS,nmclion Linux.media.nowPlaying()(players:MediaPlayer[]withid,name,state,title,artist,album,artwork,url,app,position,duration, playing first;system_wide: whether a system-wide source is there: the bundled MediaRemote adapter ornowplaying-clion macOS,playerctlon Linux;unasked, macOS: the running players macOS has not been asked about,{ id, name, app }each, since a listing never fires the Automation consent alert),control(player, command)(play_pause,play,pause,next,previous; the panel stays up),ask(player)(macOS: the consent alert forspotifyormusic, from a pick only; true once allowed). Spotify and Music over AppleScript plus the system's Now Playing as thesystemplayer on macOS (a title-less player with a state is one that reports no track, Chrome for one),playerctlon Linux.- Keycast, the feature (Features), through the
raw bridge:
core.call("keycast.status")answers{ available, reason?, active, mode, input_monitoring, settings },core.call("keycast.start", { mode? }),"keycast.stop"and"keycast.toggle", { mode? }(keys,cursor,both) drive the overlay and answer the same status. The feature publisheskeycast/activeandkeycast/modeas states. - Snippets, the Text expansion feature's (Features):
core.call("snippets.list")answers the stored list ([{ id, name, keyword?, text }]),core.call("snippets.set", { snippets })replaces it. The bundled Snippets palette is built on these.
Shared helpers
The helpers the bundled extensions share, on the same import (sdk/src/rows.ts,
text.ts, exec.ts, token.ts, png.ts, image.ts, and the namespaces):
- Rows:
hint(id, name, subtitle?, { icon?, actions?, section? }): an inert row that tells the user something (hint:prefixed, no actions, theHINT_GLYPHinformation mark unless an icon is given);toast(title, message?, style?)andfailed(what, error)("Could not " with the error's message): a pick's answer with the panel kept open. For a view:text(value, extra?),row(children, extra?),column(children, extra?)(stacks, two steps of gap),keycap(keys, action?),keyHint(keys, what, { action?, size? }): keycaps then a muted caption, the footer line;POPOVER_W(396), the width a bar popover's view measures fixed widths against;preview(detail, title, actions, ids), a row's preview (Preview, under View palettes). - Ignoring until it changes:
ignoreStore(key?, migrate?)keeps ids out of a list and its count, each with the stamp its row had when it was ignored (the newest message's id or time, anupdatedAt);hides(id, stamp)is false again as soon as the row carries another stamp, andsettle(rows, gone?)over every fetched list drops what changed, whatgonenames (a merged pull request) and what no list has carried for 30 days. Anullstamp never changes: hidden until shown again by hand (GitHub's mute). Stored underkeyin the extension's storage, local only: an ignored chat stays unread where it lives. The inboxes put it oniin their popovers andcmd+shift+ion their rows, with Show again. - Text:
bytes(n)("3.2 KB", "1.5 MB", "1.50 TB"),truncate(s, n)(an ellipsis as the last character),oneLine(s)(whitespace runs as one space, invisible characters such as a mail preheader's zero-width joiners out),slug(s),appName(bundleId)(com.google.Chromeas "Chrome": a small table, else the last segment; what a clipboard entry'ssource_appreads as),errorMessage(e)(an Error's message, else the value as text),mdEscape(text)(plain text as markdown that reads as the text, links left whole). - Paths:
home(path)above, andtilde(path), its reverse for subtitles. - Time:
now(), unix ms;PAL_NOW(2026-09-16T10:30:00, local toTZ) pins it, so a test fixes the day and the hour. Every read of the time in an extension should go through it. Reading a duration:parseDuration(s)(90s,25m,1h30m,1d, a bare number as minutes; seconds, orundefined), what States' holds and System's Keep Awake read. Writing a moment:clock(t)(14:05, 24 h),dayName(t)(Fri 18 Sep),dayNameYear(t),isoDay(t)(2026-09-18),when(t)(the clock alone today, the day before it on another day, the year in another year) andago(t)(just now,23 s ago,5 min ago,2 h ago,3 d ago,2 w ago,5 mo ago,1 y ago,in 2 h;ago(t, { short: true })isnow,5m,2hfor a narrow column or the bar;{ now }fixes the reference), all local and hand-formatted: the host runs under whatever locale launchd gave it (en-USon a machine set toen_TR), sotoLocaleStringputs the month first and an AM/PM on a user whose clock says 14:05. Use these for a row's subtitle and a pane's metadata; a{ date }accessory stays a raw instant (the UI draws it relative). - Processes:
exec(argv, { ms?, cwd?, stdin?, env? }):{ code, out, err, timedOut }, killed afterms(EXEC_MS, 10 s);run(argv, opts): stdout, or a throw with stderr, the exit code, or " did not finish in N s".listProcesses(): the process table asProc[](pid,ppid,uid,cpu,rssin KiB,comm,name) from oneps(PS_ARGV,parsePs(out)); what Processes lists and Stats ranks. - Tokens:
parseToken(out, now?): the bearer token a command printed (a bare line, or JSON withaccess_tokenand its expiry) and when it stops being good;mintToken(command, now?)runs it throughsh -c, aTokenError(stderr,code) when it printed none. Calendar's and Gmail's accounts are built on it. - Pictures:
pngSize(head):{ width, height }off a PNG's first 24 bytes;imageData(url): a picture on the web as a data url for animagenode, fetched once and kept (forgetImages()for tests);copyImage(path, fmt?): a PNG or JPEG file onto the clipboard as an image (AppleScript's«class PNGf»/«class JPEG»on macOS,wl-copyorxclipon Linux), true when it landed, false for any other format so the caller falls back tocopy_files. Images and Immich copy pictures with it. terminal: a terminal window:terminal.open(cmd, want?, cwd?)/terminal.argv(...)run a command in a fresh window (the app by name or found in /Applications,$TERMINALor the first installed on Linux),terminal.at(cwd, name)/terminal.on(tail, ...)open a shell in a folder;terminal.linux(),terminal.linuxArgv(),terminal.quote().files: the rename, move and copy forms and their submits (renameForm,moveForm,copyForm,renamePick,intoFolderPick,moveTo,copyTo),archive(paths)(ditto/zip),quickLook(paths)(qlmanage -pdetached, macOS; false elsewhere).md: markdown to the view tree:md.render(text, { width?, maxNodes?, padding?, shift?, dropTitle?, where? }),md.parseBlocks,md.inline,md.frontmatter,md.outline,md.plain,md.excerpt.colors: colour maths (parse any CSS colour, the conversions, tints and shades, harmonies, contrast, the nearest name, a swatch); the sampler iscolor.tabs: the browsers' open tabs:tabs.active(),tabs.find(url),tabs.focus(tab),tabs.pause(tab). A tab from a Chromium browser on its DevTools port carriesmedia: playing, audible, muted, andposition, the whole seconds into its longest video once it has started (the Apple TV plays a YouTube tab from there and pauses it).openUrl(url, { app?, background? }): a web address in a named browser (open -aon macOS, its command on Linux) and/or behind the app in front (open -g; the panel stays, with a toast); with neither, the plain{ open }.browsers()lists the ones installed,BROWSERSall it knows. Quicklinks' Open with and Google Search's Open in.htmlText(s): an HTML fragment (a snippet's<b>,&) as one line of plain text.
The protocol's types ride along: Extension, Palette, Item,
Accessory, Metadata, Action, Icon, TileIcon, TintedIcon,
TileColor, Effect, CopyText, WindowLayout, WindowLayoutRequest,
WindowLayoutOptions, Ctx, Detail, View, ViewNode, ViewInput,
ViewTrigger, ViewShown, Transition, Form, FormField,
FormValues, BarItem, BarMenu, BarMenuNode, BarSegment,
BarColor, BarCtx, BarSource, InstanceInfo, Manifest,
ManifestPalette, ManifestBar, ManifestLink, SettingSpec, and the
API's own (ClipboardEntry, ClipboardListOpts, Window, Rect,
Display, Applied, SystemCommand, App, InstalledExtension,
StoreState, StoreStatus, StoreRegistry, StoreListing, StoreBuild,
StoreBuildInfo, AvailableExtension, StorePending, StoreRolledBack,
StoreLeftOver, StoreResult, StoreFrom,
AudioDevice, BluetoothDevice, WifiStatus, WifiCurrent,
WifiKnown, WifiNetwork, WifiScan, WifiScanMode, MediaPlayer,
NowPlaying, MediaCommand, Permissions, PermissionId,
PermissionStatus, Calendar, CalendarEvent, CalendarStatus,
Attendee, NewCalendarEvent, Color, Dialog, SettingWrite,
ViewUpdateOptions).
Dependencies: a package.json next to index.ts is honoured; pal install runs bun install --production in the copy it makes. List
@zcag/pal under devDependencies (it is for the editor; the host
provides it at runtime), so that install skips it.
Install, update, remove
From the panel's Store palette, from the settings window's Extensions
page (Browse installs, each extension's page has Update now, Turn off,
Remove and Remove and forget, Registries adds and removes registries), or
from the CLI (pal install NAME, see CLI). A source install
(below) is the CLI's alone: it is for developing an extension.
By name, from a registry. The name is looked up in the registries'
signed indexes, pal's own first (Registries and packages).
The newest build that runs here is downloaded, its signature checked
against the registry's key, the package unpacked (only files and
directories under one <name>/ top directory) and its tree hash and
pal.json name compared with what the index lists. Only then is it put
into the store's <name>/, in one rename, and listed in the config's
[store] installed.
From a source. A spec is:
github:user/repo,github:user/repo/sub/dir, either with@ref(a branch, tag or commit; the default branch otherwise);https://github.com/user/repo, or.../tree/<ref>/sub/dir;- a local directory, which is copied (without
node_modulesand.git).
Install fetches the codeload tarball, validates the manifest, runs
bun install --production when there is a package.json (120 s), and
renames the finished copy into the store's <name>/ in one step, so
nothing ever sees it half-done. A source install is never updated by
itself; pal update NAME fetches its source again.
A name already installed is refused (update it instead), and so is a
name one of your general.extension_dirs has, or a bundled extension's
name from a registry other than pal's own. .pal-install.json in the
directory records where it came from: the registry, channel and build
(hash, seq, protocol, commit), or the source spec. Every change
holds a lock on the store, so the CLI and the app never interleave.
Updates. One check answers everywhere (Settings, the Store, pal update, pal list): an extension from a registry, and a bundled one
against pal's registry, has an update when the newest build that runs
here has a later seq and a different hash. The copy an update replaces
is kept as the previous version in <data dir>/extensions-previous/<name>,
outside every extension root, and a rollback swaps it back; the build
rolled away from is not offered again on this machine.
pal remove NAME deletes the directory. Its keys in the config file stay
([extensions.<name>], [palettes.<id>]), and so does its storage, so a
reinstall finds its settings. Remove and forget (Settings › Extensions,
on the extension's page) deletes those too, with its storage, cache,
ranking and keychain secrets.
A running app follows each of these at once, whichever did it (Settings,
the Store, a link, the CLI): the host loads or drops that one extension,
nothing else restarts. An update that fails to load where the build before
it loaded is rolled back by itself, and Settings says so with the error.
An extension's requires are installed before it; one that others
require is not removed until they are.
The Store, Settings and the SDK calls answer once that is done.
Writing one
An extension is a directory; nothing else is needed. The walkthrough
below is what examples/hello-extension/ is, step by step.
-
A directory and its manifest:
mkdir hello && cd hello cat > pal.json <<'EOF' { "name": "hello", "title": "Hello", "version": "0.1.0", "icon": "👋", "settings": [ { "kind": "text", "id": "greeting", "label": "Greeting", "default": "Hello" } ] } EOF -
The API, for the editor.
@zcag/palis not on npm, so link it from a checkout of pal (once:bun run buildinsdk/produces the.d.tsthe editor reads,bun linkregisters the package under its name):(cd path/to/pal/sdk && bun run build && bun link) echo '{ "name": "hello", "private": true, "type": "module" }' > package.json bun link @zcag/palThat puts the symlink under
node_modulesand writes nothing topackage.json. Once the package is published this isbun add -d @zcag/pal: a dev dependency, since the host provides the package at runtime andpal installskips dev dependencies. -
The code:
// index.ts import { clock, defineExtension, settings, type Item } from "@zcag/pal"; type Settings = { greeting: string }; export default defineExtension({ palettes: { hello: { title: "Hello", icon: "👋", list: (): Item[] => [ { id: "greet", name: `${settings.get<Settings>().greeting}, world`, icon: "👋", actions: [{ id: "copy", title: "Copy greeting" }] }, { id: "time", name: "What time is it", icon: "🕰", actions: [{ id: "tell", title: "Tell me" }] }, ], pick: (id) => (id === "greet" ? { copy: `${settings.get<Settings>().greeting}, world` } : { toast: { title: clock(Date.now()) } }), }, }, }); -
Install it:
pal install .copies the directory into the store (withoutnode_modules), and the host loads it. Typehelloin the panel. -
Edit and reload.
pal update hellocopies the directory again (the install recorded where it came from;pal install .a second time is refused, the name is taken), and the host reloads the extension: the store is watched, a changed file reloads its extension in place. For a real edit loop skip the copy: add the directory you are editing togeneral.extension_dirs(Config) and every save reloads it, the rows in the panel following on the next open.⌘Rin the panel lists it again by hand.
The example's full form (three rows, a setting with a description, the
manifest's palettes block) is examples/hello-extension/. Install it
from a checkout with pal install path/to/pal/examples/hello-extension,
or from GitHub with pal install github:zcag/pal/examples/hello-extension@main;
change the greeting under Settings › Extensions › Hello and the row follows.
Packages: pal-pack
A package is an extension built: what a registry serves and what the app
bundles, in one format (the contract is Registries and packages).
pal-pack, in @zcag/pal (sdk/bin/pal-pack.ts), is the one tool that
builds and hashes them, for pal's own extensions and anyone else's:
weather/
pal.json the manifest, "protocol" stamped in
index.js bun build of index.ts; its dependencies inlined
*.js chunks, one per dynamic import
surface/ a game's page, with the *.ts beside index.ts it imports
Nothing else: no node_modules, no dotfiles, no index.ts (the host would
load it over index.js). @zcag/pal is not inlined: it is left an
import and resolves at run time to the SDK of the pal running it (the link
the host makes in each root, or the copy in the bundled root), so a package
calls the API of the app it runs in, and a package is only rebuilt when its
own code changes. What that API may change is what PROTOCOL guards: it is
an integer in sdk/src/protocol.ts (import { PROTOCOL } from "@zcag/pal"),
raised when a change to the SDK or the host breaks extensions built before
it. A pal runs packages stamped from PROTOCOL_MIN to PROTOCOL, and
hello on the host's wire reports both.
bunx pal-pack build extensions/* # dist/<name>/, dist/<name>.tar.gz, dist/<name>.entry.json
bunx pal-pack statements dist # dist/<name>.statement, for minisign
minisign -S -s key -m dist/*.statement
bunx pal-pack index dist --name acme --base https://acme.github.io/pal --out site
minisign -S -s key -m site/index.json
build <dir>... [--out dist] [--cwd <root>] [--seq N] [--commit SHA] [--screenshots-base URL] [--dir-only]runsbun buildin--cwd(default: the git checkout's top, so the source-path comments in the output are the same wherever it runs), and prints one line per extension: name, hash, seq, protocol and the tarball's size.seqis the commit's time andcommitits sha, from git unless given; outside a git checkout pass--seq. The same source gives the same hash and the same tarball bytes.--screenshots-basemakes the listing's screenshots<base>/<name>/screenshots/<file>; without it the listing has none.--dir-onlywrites the package directory alone with.pal-build.json(its hash, seq, commit and protocol) inside, which is how the app bundles them.statements <dist>: the text each build is signed over.index <dist> --name <registry> --base <url> --out <dir> [--merge index.json] [--yank name@hash]... [--keep a,b] [--next-key KEY]: the index andpkg/<name>/<hash>.{tar.gz,json}under--out. Every entry needs its<name>.statement.minisig.--mergebuilds on the index you serve now, keeping the newest build per protocol and the one before it;--keepnames the extensions that stay (the rest of the merged ones leave);--next-keyannounces the key you are moving to (""clears it).promote --from edge.json --to stable.json --out index.json [--names a,b] [--yank name@hash]...: the newest good build of each extension in one index added to another, as pal's own edge and stable are.
Store screenshots
The store (pal.cagdas.io) shows what screenshots/ holds, captioned by
pal.json's store.screenshots. Every picture comes as a pair,
<name>.png on the light theme and <name>-dark.png on the dark one, and
the store shows the one matching the visitor's scheme; list the light file
with its caption and kind (panel, or bar for a bar item's picture).
Sizes: a panel shot 1440 by 900 (the panel on a wallpaper), a bar strip
1440 by 120 (bar-menubar, bar-sketchybar), a popover 1440 by 1080
(bar-popover); bar-menubar-<state> and bar-popover-<state> for other
states. Invented data only: nobody's real mail, calendar or music.
The pictures of pal's own extensions are generated, never captured: a
fixture.ts beside index.ts builds them from the extension's own code and
make shots EXT=<name> in its repo renders both themes, writes the list
and stamps it; that repo's make test refuses a set that is missing,
stale, the wrong size or unlisted. The whole contract is
docs/design/screenshots.md.
Trust
An extension is code that runs with your user's rights inside the host.
There is no signing and no vetting: install what you would run from a
terminal. bun install also runs the dependencies' install scripts.
Rendered from docs/extensions.md in the pal repo, 2026-11-02.