@wildride/pad-kit
The gamepad kernel, extracted from game-kube.
Everything a browser app needs to turn navigator.getGamepads() into a mapping it
can trust: the stored profile schema, the capture engine that builds one,
the decode that applies it, the resolution gates that refuse to apply a
wrong one, the glyph sets that label a button the way the pad in the user's
hands does, and the diagram geometry that draws it.
The kernel is dependency-free — no framework, no HTTP client, no DOM. A synthetic
{ id, mapping, buttons: [{pressed, value}], axes: [] } object literal is a valid
"Gamepad" to every function in it. Three optional Vue 3 components ship alongside
for the presentation layer; vue is an optional peer dependency, so a pure
node consumer (a server-side profile validator, the geometry checker) never
resolves it.
Install
npm install @wildride/pad-kit
import { archetypeSteps, SCHEMA_VERSION } from '@wildride/pad-kit/schema';
import { applyProfile, profileToRemap } from '@wildride/pad-kit/apply';
import { restSnapshot, detectInput } from '@wildride/pad-kit/capture';
import { createResolver } from '@wildride/pad-kit/resolver';
import { resolveGlyph } from '@wildride/pad-kit/glyphs';
import { CONSOLE_SHAPES } from '@wildride/pad-kit/padShapes';
import { hitTest, moveControl } from '@wildride/pad-kit/padGeometry';
import { pickCandidate } from '@wildride/pad-kit/lrud';
import { PadDiagram, PadButtonGlyph, GlyphChip, providePadGlyphContext }
from '@wildride/pad-kit/components';
import '@wildride/pad-kit/tokens.css';
The root entry (import * as padKit from '@wildride/pad-kit') hands back one
namespace per module rather than a flat barrel, because capture.js and
resolver.js deliberately each carry their own parseVendorProduct /
isXInputWrapper and a flat re-export would make those ambiguous.
What's in it
./schema | SCHEMA_VERSION, the semantic BUTTONS/STICK_AXES/TRIGGERS vocabulary, CONTROLLER_TYPE, the ARCHETYPES step lists, platformBucket(), profileId() |
./apply | applyProfile() (live pad → semantic names), profileToRemap() (semantic → wire flags), hatDirections, triggerValue, stickValue, BUTTON_FLAGS |
./capture | restSnapshot, detectInput, selectForStep, resolveCapture, buildProfile, verifyProfile, parseVendorProduct, isXInputWrapper — the registration wizard's engine, DOM-free |
./resolver | createResolver({ fetchProfile, saveProfile }) plus the stateless gates (gateProfile, REJECT, mergeResolvedLayers, resolveProfileFrom, buildRemapTable) |
./glyphs | GLYPH_SETS, resolveGlyph(), diagramLabels(), sniffGlyphSet(), NAV_TO_SEMANTIC, PS_CLASS, ARROW_POINTS |
./padShapes | PHYSICAL_SHAPES (per archetype), CONSOLE_SHAPES (per console family), PAD_SCALE, physicalShape(), consoleShape(), normalizePadLayout() |
./padGeometry | Pure editor math over a shape: hitTest, moveControl, setControlField, snap, coverageDiff |
./lrud | pickCandidate() / isDegenerate() — geometric spatial navigation (BBC lrud-spatial / W3C spatial-navigation model). DOM-free: callers pass DOMRect-likes |
./components | PadDiagram, GlyphChip, PadButtonGlyph, providePadGlyphContext |
./tokens.css | Default values for the CSS custom properties those components read |
./tools/pad-geometry-check.mjs | The mechanical geometry checker, as a CLI and as importable pure rules |
./testkit/* | padModel.mjs / padShim.mjs — a fake gamepad and the Playwright init-script builder that installs it in a page |
What's deliberately NOT in it
consoleFit.js + consoleFit.json | The libretro/RetroPad fit model: which console family a core presents, how a physical pad trades onto it, which control is unreachable. That is emulator-frontend domain knowledge, not gamepad knowledge — a non-emulator consumer of this kit would carry a 30KB table it can never use. It reads padShapes and glyphs from here; the dependency points the right way. |
consoleRemap.js | Same reason — it is the remap layer over the fit model. |
internal/gamepaddb (Go) | Server-side profile storage. The kit is the browser half. |
The wizard/panel app surfaces (GamepadCaptureStep.vue, ConsoleFitPanel.vue, PadLayoutPanel.vue, …) | Product UI: routing, stores, notifications, the app's own layout. They compose the kit; they are not part of it. |
PadDiagram / GlyphChip / PadButtonGlyph came across because they are pure
presentation over kit data — they read no store, fetch nothing, and route nowhere.
The resolver's injection API
resolver.js is the one module that used to reach for the host app. In game-kube
it imported apiRequest/getAuthToken from the app's api client and the URL from
the app's endpoint table — two touchpoints, both about where the profile table
comes from. The kit takes them as injected functions, so the gates ship with no
transport opinion:
import { createResolver } from '@wildride/pad-kit/resolver';
export const resolver = createResolver({
fetchProfile: () => {
if (!getAuthToken()) return null;
return apiRequest('/gamepads/resolved', { method: 'GET' });
},
saveProfile: (profile) => apiRequest('/gamepads', { method: 'POST', body: profile }),
});
null is a real answer, not an error. A caller with no credential gets "no
table", and the pad still works through the browser's own standard mapping. This
is load-bearing: an api client that treats a 401 as "your session ended" will
clear the token and navigate away, and the pad database must never be able to take
down the page that asked for it. A null answer is not cached — it is a statement
about the caller's credential, not about the table.
createResolver returns the full surface: resolveProfile, remapTable,
loadProfiles, loadRemapTable, setProfiles, clearProfiles, getProfiles,
profilesLoaded, saveProfile, plus the stateless REJECT, gateProfile,
parseVendorProduct, liveMappingState, isXInputWrapper,
mergeResolvedLayers. Create one instance per app, at module scope — the
profile cache is shared state on purpose (the wizard seeds it with setProfiles
straight after a save; the stream path and the nav path both read it back).
The gates are the point
A stored profile is a set of raw hardware indices. Applied to the wrong pad
state or the wrong report shape it does not degrade gracefully — it silently means
something else, and the user cannot tell it is wrong. So gateProfile refuses
rather than adapts, and a refusal drops the pad to the next tier:
user override > global verified > global unverified > your seed > browser standard
SCHEMA | The profile's schemaVersion is newer than this build's |
IDENTITY | No vendor/product in the pad id, or it doesn't match |
MAPPING_STATE | The browser normalized this pad on one platform and not the other. Never adapt across that line |
REPORT_SHAPE | Button/axis counts differ. Equality, not tolerance — one extra button is not the pad that was captured |
The token layer
The Vue components style themselves entirely from CSS custom properties. Import
the kit's defaults before your own token sheet so the cascade lets yours win:
import '@wildride/pad-kit/tokens.css';
import './styles/tokens.css';
Values are space-separated R G B integers, not rgb(...) strings, because
the components read them as rgb(var(--token) / 0.3). A hex value breaks every
rule that uses an alpha modifier.
The list is measured from the components' style blocks, not aspirational:
- PadDiagram —
--surface-sunken --surface-overlay --edge --edge-strong
--ink-secondary --ink-muted --color-brand --color-danger --color-warn
--color-action
- GlyphChip / PadButtonGlyph —
--surface-overlay --edge-strong --ink
--color-brand --color-live --color-danger --color-warn --radius-lg
A host that already defines these names does not need to import tokens.css at
all.
Glyph context
PadButtonGlyph learns which pad is in the user's hands through provide/inject,
not through a store:
providePadGlyphContext({
glyphSet: computed(() => ui.state.glyphSet),
glyphOverrides: computed(() => ui.state.glyphOverrides),
});
Both fields take a ref/computed or a plain value. Nothing is required: with no
provider at all every chip resolves through the generic set. A chip must degrade,
never throw.
Versioning
SCHEMA_VERSION is the contract. Stored profiles are indices into real
hardware; a consumer that reads a profile written by a newer schema than it
understands is the exact failure the gates exist to prevent.
- A
SCHEMA_VERSION bump is a MAJOR release. No exceptions, however additive
the change looks.
- Adding a glyph set, a pad shape, an archetype, or a component prop: MINOR.
- Changing a
PAD_SCALE radius, a shape's geometry, or a BUTTON_FLAGS value:
MAJOR (the wire flags are pinned in lockstep with a stream core; the radii are
pinned by the geometry checker).
- Consumers pin exactly. game-kube's
web-ui/package.json carries an exact
version, not a range.
Consumers
- game-kube (
web-ui/) — the origin. Registration wizard, console-fit /
remap panels, in-stream pad mapping, FR-41 controller navigation.
- (second consumer — this repo exists because the kernel outgrew one app.)
Development
npm install
npm test # vitest — the ported specs plus the kit's component spec
npm run check:geometry # node tools/pad-geometry-check.mjs — every built-in shape
tools/pad-geometry-check.mjs is both a CLI and a set of importable pure rules.
test/padGeometryCheck.spec.js hands it the same tables the bundler resolves, so
npm test runs the geometry rules over the real built-ins — the checker cannot rot
by nobody remembering to type the command.
node tools/pad-geometry-check.mjs # every built-in
node tools/pad-geometry-check.mjs layout.json # a spec.padLayout document
node tools/pad-geometry-check.mjs layout.json --family ps2
Authoring or re-measuring a shape is .claude/skills/pad-geometry (/pad-geometry):
it grids a reference photograph, reads control centres off it mechanically, rasterizes
the result the way PadDiagram.vue draws it, and runs the checker before showing
anyone a shape. The photographs live in a gitignored gamepad_refrence_images/ and
are a reference, never a source — no outline is traced, no image or crop is embedded,
and what lands in a diff is integers. Note that a CONSOLE.* control-id set is a
cross-repo contract: consoleFit.js stayed in game-kube, so that is where the
coverage-both-ways pin lives, and a renamed or added id passes here and fails there.
Phase 0 note
During the extraction, the specs under test/ also still live in game-kube
(web-ui/src/gamepad/__tests__/, web-ui/src/composables/__tests__/lrud.spec.js),
running against re-export shims. That duplication is deliberate and temporary: it
is the equivalence proof that the extraction changed nothing. Two of them differ on
purpose — game-kube's apply.spec.js and resolver.spec.js additionally pin
against its vendored stream core (StreamControllerButton, the core's own
parseVendorProduct), which the kit has no copy of; the kit pins the same values as
literals and cross-checks resolver's parser against capture's.