Command Registry
One declarative list of every destination and action on the site.
RegistriesLiveJavaScript
60commands
What it is
A single source of truth for the site’s stable destinations and actions, role-gated and grouped. The nav, footer sitemap, omnibar and concierge all read it — so they never re-derive their own lists.
Take it with you
The real, committed source behind this system — copy it or download the file. Plus a portable spec of everything on this page.
registry.mjsjavascript151 lines
The generic, domain-free command-registry core (createCommandRegistry) — one action list, many surfaces.
// Generic, domain-free command-registry core.
//
// This is the reusable machinery behind a "command registry" — one declarative
// source of truth for "things a user can do" that several surfaces (menus, a
// Cmd-K palette, an agent) read from instead of each re-deriving an action list.
// Heddle (`src/lib/heddle/commands.mjs`) was the first consumer; the main site
// (`src/lib/site/commands.mjs`) is the second. Built three times, an action list
// drifts three ways — so the algorithm lives here once and each domain supplies
// only its command set, its role lattice, and (optionally) its scope rule.
//
// Design rules (carried over from the Heddle UX-014 substrate):
// - Declarative & pure. No server-only imports, so a client surface can import
// a registry without bundling a database client. Role filtering here is for
// *display* — real enforcement still happens server-side in each route.
// - Handlers are thin. A handler only describes the route to hit or where to
// navigate; it contains no business logic.
// - Role filtering is centralized. Surfaces call `commandsForContext` and get
// an already-filtered list; they must not re-check `minRole`.
// - `write` commands declare a `consequencePreview` so every mutating command
// can route through a shared confirmation component.
//
// Each command declares:
// id stable identifier
// label user vocabulary, never machine idiom
// keywords extra search terms for a palette
// scope a domain scope string ("global" always shows)
// minRole a key of the domain's role lattice
// kind one of COMMAND_KINDS
// costClass one of COST_CLASSES (AI spend class)
// destructive optional bool — revokes access or takes work offline
// handler(ctx) thin wrapper → a request/navigate/client descriptor
// consequencePreview(data) optional (required for write) → preview object
// "write" is a server mutation (an HTTP request descriptor); "action" is a
// client-side effect (e.g. a theme toggle) that hits no route. Both are
// non-navigation, so a menu groups them together unless `destructive`.
export const COMMAND_KINDS = ["navigate", "read", "write", "action"];
export const COST_CLASSES = ["none", "light", "heavy"];
// Build an id → command lookup, rejecting duplicate ids defensively.
export function indexById(commands = []) {
const byId = new Map();
for (const command of commands) {
if (byId.has(command.id)) {
throw new Error(`Duplicate command id in registry: "${command.id}"`);
}
byId.set(command.id, command);
}
return byId;
}
// Compare a user's role against a command's minimum, using a domain lattice
// (e.g. { viewer: 0, editor: 1, owner: 2 } or { guest: 0, admin: 1 }).
export function roleSatisfies(roleRank, role, minRole) {
if (!roleRank || !(role in roleRank) || !(minRole in roleRank)) return false;
return roleRank[role] >= roleRank[minRole];
}
// Default scope visibility: a "global" command shows in every context; any other
// scope must match the surface's context scope exactly. This faithfully
// reproduces Heddle's original rule (exam-scoped commands hidden outside an exam)
// while generalising to any scope vocabulary. Domains may inject an override.
export function defaultScopeMatches(commandScope, contextScope) {
return commandScope === "global" || commandScope === contextScope;
}
/**
* Bind the generic algorithm to a domain's command set + role lattice.
*
* @param {object} config
* @param {Array} config.commands the domain's command array
* @param {object} config.roleRank role → numeric rank lattice
* @param {(commandScope: string, contextScope: string) => boolean} [config.scopeMatches]
* @returns the bound registry API (same surface every consumer relies on)
*/
export function createCommandRegistry({
commands = [],
roleRank,
scopeMatches = defaultScopeMatches,
} = {}) {
const byId = indexById(commands);
function getCommandById(id) {
return byId.get(id) || null;
}
/**
* Is this command available to a user with `role` in the given `scope`?
* Surfaces must not re-check `minRole` — this is the single gate.
*/
function canRunCommand(command, { role, scope } = {}) {
if (!command) return false;
if (!scopeMatches(command.scope, scope)) return false;
return roleSatisfies(roleRank, role, command.minRole);
}
/**
* The role-filtered command list for a surface — the single place permission
* filtering happens.
*
* @param {object} context { role, scope }
* @param {object} [opts]
* @param {string} [opts.search] case-insensitive match over label + keywords
* @param {string} [opts.kind] restrict to one kind
*/
function commandsForContext(context = {}, opts = {}) {
const { search, kind } = opts;
const needle = typeof search === "string" ? search.trim().toLowerCase() : "";
return commands.filter((command) => {
if (!canRunCommand(command, context)) return false;
if (kind && command.kind !== kind) return false;
if (needle) {
const haystack = [command.label, ...(command.keywords || [])]
.join(" ")
.toLowerCase();
if (!haystack.includes(needle)) return false;
}
return true;
});
}
/**
* Resolve a command's thin handler into a request/navigate/client descriptor.
* `ctx` supplies route params and an optional body.
*/
function resolveCommand(command, ctx = {}) {
if (!command || typeof command.handler !== "function") {
throw new Error(`Command "${command?.id ?? "?"}" has no handler`);
}
return command.handler(ctx);
}
/**
* Build a command's consequence preview from the data it needs, or null if the
* command declares none. Write commands always declare one.
*/
function commandConsequence(command, data = {}) {
if (!command || typeof command.consequencePreview !== "function") return null;
return command.consequencePreview(data);
}
return {
COMMANDS: commands,
getCommandById,
canRunCommand,
commandsForContext,
resolveCommand,
commandConsequence,
};
}
Where it lives
- src/lib/site/commands.mjs
- src/lib/command-registry/registry.mjs
See it in action
Architecture map →FAQ
What reads the command registry?
The top nav, the footer sitemap, a Cmd-K omnibar and the concierge — all from one declarative list, so they stay in sync.
Part of these stacks
Related systems
Want a system like this built for you?Work with me →