@pixelmatters/tweaks
v1.3.0
Published
Headless dev-only scenario switcher: a config-driven tweaks store and panel (React and Vue) for forcing unhappy paths in sandbox and demo apps.
Maintainers
Readme
@pixelmatters/tweaks
A dev-only panel for forcing the states a demo can't normally reach: a failed load, a save that errors, an expired session, a forbidden page. A floating button opens a small non-modal card, you pick a scenario, and the page behind it reacts.
Each screen declares its scenarios in a config. The panel renders controls from that config, and the page reads the chosen values back and branches on them. The store and panel logic are framework-free, with bindings for React and Vue.
What you get
- Scenarios are data. You write a config and the panel renders it, so your app contains no panel UI code.
- The types follow the config.
defineTweaksConfigrejects duplicate ids, adefaultthat isn't one of the field's options, and ids widened tostring, all at the call site.config.useTweak(id)autocompletes ids and returns that field's value type. /scenarioshas ready-made fields for the unhappy paths every project ends up needing (load state, action outcome, session timeout, permission denied, unsaved changes), so they behave the same in every app.- Forced values are saved to
sessionStorage, so a scenario survives the reload it often triggers. Each screen's panel also remembers whether it was open. - Reads work without a provider and return the default. Put the provider and
panel behind
import.meta.env.DEVand production renders the happy path with no changes at the call sites. - The panel uses native controls and injects its own stylesheet from JS: no
design system dependency, no CSS import. Restyle it with CSS variables and
classNames. - For your own UI,
useTweaksPanelgives you sections, the badge count, reset and open state without any markup. - A crashing field renderer shows an error inside the card instead of taking down your app.
- The server render and hydration read the store as empty, so hydration matches the default HTML.
- Controls have real labels, segmented fields are radio groups, Escape only acts inside the card, focus goes back to the button on close, and reduced motion is respected.
Install
# pnpm
pnpm add -D @pixelmatters/tweaks
# or yarn
yarn add -D @pixelmatters/tweaks
# or npm
npm install -D @pixelmatters/tweaksPeer dependencies are React 19+ or Vue 3.5+. Both are optional; install the one
your app uses. The package ships compiled ESM with .d.ts types, and the CSS is
inlined into the JS, so your bundler doesn't need to handle it.
Quickstart
Mount the provider once, at the app root:
import { TweaksProvider } from '@pixelmatters/tweaks/react'
createRoot(document.getElementById('root')!).render(
<TweaksProvider storageKey="my-app.tweaks">
<App />
</TweaksProvider>,
)Then declare a screen's scenarios, read them through the config, and render the panel on that screen:
import { defineTweaksConfig, TweaksPanel } from '@pixelmatters/tweaks/react'
const CONTACTS_TWEAKS = defineTweaksConfig({
id: 'contacts',
title: 'Scenarios',
sections: [
{
title: 'Data state',
fields: [
{
id: 'loadState',
label: 'Load state',
kind: 'enum',
default: 'loaded',
options: [
{ value: 'loaded', label: 'Loaded' },
{ value: 'empty', label: 'Empty — no contacts yet' },
{ value: 'error', label: 'Failed to load' },
],
},
],
},
],
})
function Contacts() {
const loadState = CONTACTS_TWEAKS.useTweak('loadState')
// ^? 'loaded' | 'empty' | 'error'
// …branch on loadState…
return <TweaksPanel config={CONTACTS_TWEAKS} />
}The common cases already exist as ready-made fields:
import { loadStateField, systemOutcomeField } from '@pixelmatters/tweaks/scenarios'
const CONTACTS_TWEAKS = defineTweaksConfig({
id: 'contacts',
title: 'Scenarios',
sections: [
{ title: 'Data state', fields: [loadStateField()] },
{ title: 'Actions', fields: [systemOutcomeField('saveOutcome', 'Save contact')] },
],
})Keeping it out of production
Without a provider, useTweak returns the field's default. Put the provider and
panel behind your dev flag and every call site renders the happy path in
production:
{
import.meta.env.DEV ? (
<TweaksProvider storageKey="my-app.tweaks">{children}</TweaksProvider>
) : (
children
)
}The write hooks (useTweaksControls, config.useControls, useTweaksPanel)
throw without a provider. That's on purpose, since they should only ever run in
dev-gated code.
Entry points
| Import | Contents |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @pixelmatters/tweaks | Framework-free core: createTweaksStore, the TweaksConfig schema and types, the define<Kind>TweakField factories, and the panel logic a custom renderer needs (resolveSections, countActive, resolvePresentation, resolveTweakValue, resolveRecordValue) |
| @pixelmatters/tweaks/react | TweaksProvider, defineTweaksConfig, the hooks, the default <TweaksPanel>, and the headless useTweaksPanel |
| @pixelmatters/tweaks/vue | The same surface for Vue: TweaksProvider, defineTweaksConfig, the composables, the default <TweaksPanel>, and the headless useTweaksPanel (Vue) |
| @pixelmatters/tweaks/scenarios | Ready-made field definitions, id constants, and value-union types for the recurring unhappy-path families |
Fields
There are three kinds. kind says what the value means; how it renders is a
separate, optional presentation hint.
kind: 'enum'
One value from a fixed set. With three options or fewer it renders as a
segmented control, otherwise as a select. resolvePresentation makes that call,
or you set presentation: 'segmented' | 'select' yourself. Short lists with long
labels usually read better as 'select'. 'segmented' with more than five
options is a compile error, because it won't fit in the card.
{
id: 'loadState',
label: 'Load state',
kind: 'enum',
default: 'loaded',
options: [{ value: 'loaded', label: 'Loaded' }, { value: 'error', label: 'Failed' }],
hint: 'Optional helper text, rendered under the row and wired as aria-describedby',
}Option values are what the page reads back, so treat them as API: keep them stable and change the labels instead.
kind: 'boolean'
A switch.
{ id: 'forceUnsavedChanges', label: 'Force unsaved changes', kind: 'boolean', default: false }kind: 'action'
A one-shot trigger, like "deliver the webhook now", as opposed to a state you leave switched on. Actions don't go through the store. Nothing is persisted, Reset ignores them, they don't count toward the badge, and a handler that subscribes after a press won't see it.
{ id: 'deliverWebhook', label: 'Deliver webhook', kind: 'action' }Pages handle them with useTweakAction(id, handler). If you catch yourself
modeling a trigger as an enum with a 'none' value that the page writes back
after acting on it, use an action instead.
Field families
When the same field shows up on several screens, define it once with
define<Kind>TweakField. You pass the shared defaults and get back a builder.
Each call to the builder makes a field and can override any of them: id, label,
hint, default, presentation, or individual option labels.
import { defineEnumTweakField } from '@pixelmatters/tweaks'
const loadStateField = defineEnumTweakField({
id: 'loadState',
label: 'Load state',
default: 'loaded',
options: [
{ value: 'loaded', label: 'Loaded' },
{ value: 'loading', label: 'Loading' },
{ value: 'empty', label: 'Empty' },
{ value: 'error', label: 'Failed to load' },
],
})
loadStateField() // the family as-is
loadStateField({ emptyLabel: 'Empty — no results yet' }) // per-option label override
loadStateField({ id: 'detailLoadState', default: 'error' }) // same family, second instancePer-option label overrides are named after the option values
({ value: 'empty' } becomes emptyLabel). If you leave id and label out
of the defaults, the builder requires them on every call. That's how
systemOutcomeField(id, label) works, since each action needs its own id.
Build fields with the factories, or with defineTweakField for one-offs. Don't
write a helper annotated : TweakField instead. The annotation widens the id to
string, which turns the whole config's value map into an index signature:
useTweak stops checking ids and everything still compiles.
defineTweaksConfig rejects a widened config to catch this.
Ready-made scenarios
@pixelmatters/tweaks/scenarios exports field definitions, id constants, and
the value types pages branch on:
| Export | Kind | What it forces |
| ------------------------------------------------------------- | ------- | --------------------------------------------- |
| loadStateField / LOAD_STATE_ID / LoadState | enum | 'loaded' \| 'loading' \| 'empty' \| 'error' |
| systemOutcomeField(id, label) / SystemOutcome | enum | 'success' \| 'error' for one named action |
| sessionTimeoutField / SESSION_TIMEOUT_ID | boolean | Session expired |
| sessionTimeoutWarningField / SESSION_TIMEOUT_WARNING_ID | boolean | Session about to expire |
| permissionDeniedPageField / PERMISSION_DENIED_PAGE_ID | boolean | Whole page forbidden |
| permissionDeniedActionField / PERMISSION_DENIED_ACTION_ID | boolean | One action forbidden |
| forceUnsavedField / FORCE_UNSAVED_ID | boolean | Dirty-state / unsaved-changes guards |
The package stops there. The dialog, gate, guard or toast that responds to a scenario is your app's code, so the scenarios stay consistent across apps while each app keeps its own look.
Reading and writing
Through a config (preferred)
defineTweaksConfig returns the config with hooks bound to it, in the style of
TanStack Router:
config.useTweak(id): the id must be one of the config's fields, and the return type comes from that field. There's no fallback argument because the field'sdefaultis the fallback. A stored value the field no longer accepts (after an option is renamed, say) also resolves to the default.config.useTweakAction(id, handler): the id must be one of the config's action fields.useTweakrejects action ids at compile time, anduseTweakActionrejects everything else.config.useRecord(id): reads one of the config'srecords(see below). It always returns a string; anything unset or of the wrong type reads as''.config.useControls(): write access, withsetTweak(id, value)for fields (the value limited to what the field declares) andsetRecord(id, value)for records. It doesn't subscribe to any values, so a component that only writes won't re-render when something else changes.
Standalone hooks
These are for ids that don't belong to a config, which in practice means the
provider's globalSections (below):
useTweak(id, fallback): the fallback also sets the return type.useTweakAction(id, handler)anduseTweaksControls(): unscoped versions of the config hooks.useRawTweakValue(id): the stored value as-is, orundefined. Custom renderers build on this.
Don't read a config's field through the standalone useTweak. The provider only
knows about its global fields, so the value skips stale-value resolution. That's
how you end up with a page forcing a scenario while the badge, the controls and
Reset all say everything is at its default.
Records
config.records are store keys the panel has no control for, such as a restored
file, an approval or a returned document. A flow in the page writes them with
setRecord:
const APPROVALS_TWEAKS = defineTweaksConfig({
id: 'approvals',
title: 'Scenarios',
sections: [/* … */],
records: ['lastDecision'],
})
const decision = APPROVALS_TWEAKS.useRecord('lastDecision')The badge counts every record a flow has written ('' and '{}' count as
nothing written), and Reset clears records along with fields. Otherwise Reset
would claim to undo a decision the screen still remembers.
The provider
<TweaksProvider storageKey="my-app.tweaks" globalSections={GLOBAL_SECTIONS}>storageKey(optional): thesessionStoragekey for forced values. Without it nothing persists. Open state is stored per config id, under${storageKey}:open:${configId}, so each screen remembers its own.globalSections(optional): sections that apply everywhere, like session timeout or permission denied. They're appended to every panel and read with the standalone hooks. An inline array is fine; the provider compares contents and won't recreate the context on every render.webmcp(optional, off by default): exposes the mounted panels to browser agents as WebMCP tools. See Agent tools (WebMCP).
Give every config a unique config.id. It keys the persisted open state, the
data-config-id attribute on the wrapper (handy as an e2e or debug selector),
and the dev-only warning for field ids that collide with globalSections, which
fires once per config id.
Theming
The panel comes in light and dark. Pick one with the theme prop: 'light'
(the default), 'dark', or 'system', which follows prefers-color-scheme.
If your app has its own theme toggle, pass its current value so the panel
follows it. The prop takes only those three values, so narrow a looser type
first.
'system' is resolved in CSS, so the server can render it without knowing the
answer. A theme that's only known in the browser needs care under server
rendering. Neither React nor Vue corrects an attribute that differs between the
server's HTML and the first client render, so the panel would keep the server's
theme until the value next changed. Render 'system' until the component has
mounted (useEffect in React, onMounted in Vue). With next-themes, whose
resolvedTheme is undefined on the server:
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
<TweaksPanel
config={CONTACTS_TWEAKS}
theme={!mounted ? 'system' : resolvedTheme === 'dark' ? 'dark' : 'light'}
/>To restyle the panel, redeclare the --px-tweaks-* variables on the
.px-tweaks wrapper. Two things decide whether your values win:
- Declare them on the wrapper, not
:root. The defaults live on the wrapper, and a value declared there beats an inherited one. - Keep them out of any
@layer. The panel's defaults are in a cascade layer that's added when the panel mounts. Layers rank in the order they first appear, so it outranks every layer your stylesheet declared before then, Tailwind'sbaseandcomponentsincluded. Unlayered rules beat all layers.
The wrapper carries the theme as data-px-tweaks-theme. A value on plain
.px-tweaks applies to both themes; scope one to a single theme with the
attribute:
.px-tweaks {
--px-tweaks-font: var(--font-sans);
}
.px-tweaks[data-px-tweaks-theme='light'] {
--px-tweaks-accent: var(--color-primary);
}
.px-tweaks[data-px-tweaks-theme='dark'] {
--px-tweaks-accent: var(--color-primary-dark);
}With theme="system", the attribute reads system in both themes, so the rules
above don't match. Scope the light values to system and repeat the selector
inside a media query for the dark ones:
.px-tweaks[data-px-tweaks-theme='system'] {
--px-tweaks-accent: var(--color-primary);
}
@media (prefers-color-scheme: dark) {
.px-tweaks[data-px-tweaks-theme='system'] {
--px-tweaks-accent: var(--color-primary-dark);
}
}| Variable | What it colors / sets |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| --px-tweaks-bg | Card, select and switch-thumb background, and the base every hover and tint is mixed toward |
| --px-tweaks-fg | Foreground text |
| --px-tweaks-border | Card, FAB, and control borders |
| --px-tweaks-muted-fg | Section titles, hints, the select's chevron, and the tint of the segmented track, the switch-off track and hovers |
| --px-tweaks-accent | FAB, selected segment, switch-on track |
| --px-tweaks-accent-fg | Text and glyphs on the accent color |
| --px-tweaks-badge | FAB badge background, shown while a scenario is forced |
| --px-tweaks-badge-fg | FAB badge text. Set it with --px-tweaks-badge, so the count stays readable on your color |
| --px-tweaks-danger | The message shown in place of fields that failed to render |
| --px-tweaks-ring | Focus ring |
| --px-tweaks-radius | The card's corner radius (default 1rem). Controls are fully round |
| --px-tweaks-font | Font family. Defaults to the host page's font, and it's the only variable a :root declaration reaches. Sizes are fixed |
| --px-tweaks-z | z-index of FAB and card (default 9999) |
Variables starting --_px-tweaks- are internal and can change in any release.
For per-element classes, pass classNames to <TweaksPanel>. They're appended
to the package's own classes, the way sonner does it:
<TweaksPanel config={CONTACTS_TWEAKS} classNames={{ fab: 'bottom-left', card: 'shadow-xl' }} />Keys: root, fab, badge, card, header, title, resetButton,
closeButton, body, section, sectionTitle, row, label, hint,
select, segmented, segment, switch, action, crash.
Colors and the font face come from the variables above, not from classNames.
The panel's own rules set them on its elements and load after your stylesheet,
so a color or font utility in classNames can lose to them. Use classNames
for layout, spacing and position.
You can move the FAB with classNames.fab. The card is attached to it with CSS
anchor positioning and flips sides to stay on screen.
Stacking
The FAB and card are position: fixed with z-index: var(--px-tweaks-z), so
they sit above the host's UI in the stacking context they render in. If the host
isolates its root (#root { isolation: isolate }, for example), a dialog
portalled to body paints above the panel. That's intended: a forced dialog
covers the panel, and the panel is back once the dialog closes. Raising
--px-tweaks-z won't change this, because z-index isn't compared across an
isolation boundary.
Anything rendered inside the card has to stay inside it. A portal out of the card leaves its stacking context and paints behind it.
Custom renderer
useTweaksPanel(config) is <TweaksPanel> without the markup. It has all the
behavior and injects no styles:
function MyPanel({ config }: { config: TweaksConfig }) {
const panel = useTweaksPanel(config)
// panel.open / panel.setOpen — persisted open state
// panel.sections — config + globalSections, resolved
// panel.activeCount — fields forced away from default + records written
// panel.setValue(id, value) — write one field
// panel.fire(id) — fire one action field
// panel.reset() — reset, scoped to this panel's ids
}There's no values map, on purpose: subscribing the panel to every value would
re-render every control on every write. Have each control read its own id with
useRawTweakValue(id) and resolve it with resolveTweakValue(field, value),
which is what the default renderer does. resolvePresentation(field) returns
the default segmented/select choice.
Error handling
The default panel has two error boundaries, so a bug in a dev tool can't unmount your app:
- The inner one wraps the card's controls, which is where crashes actually happen (usually a field shape a renderer didn't expect). It shows a message in their place and leaves the header working. Closing and reopening the card remounts the controls.
- The outer one wraps the rest of the panel and renders nothing if it catches an error.
Both log the config id to the console. Your pages' own useTweak calls live in
your app's tree, outside both boundaries, so a bad id there still fails as an
error in your app.
Accessibility
- Every control is named by its row label. Selects and switches get a
<label for>, so clicking the label works, and segmented fields are arole="radiogroup"witharia-labelledby. A field'shintbecomes itsaria-describedby. - Escape closes the card only when focus is inside it, and the event stops there, so a host dialog underneath doesn't close too.
- Opening the card from the FAB moves focus into it. A card that's restored already open on page load leaves focus where it is. Closing returns focus to the FAB.
- Under
prefers-reduced-motion, the open/close animation is cut to almost nothing.
Agent tools (WebMCP)
WebMCP lets a page register
tools that a browser agent can call. With the webmcp prop, the provider
registers four of them, so an agent can force a scenario with one typed call
instead of finding the panel and clicking through it:
<TweaksProvider storageKey="my-app.tweaks" webmcp>| Tool | What it does |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| tweaks_list | Every field of every mounted panel and of globalSections, with its options, default and current value, plus the records |
| tweaks_set | Forces one or more values, e.g. { "values": { "loadState": "empty" } }. All or nothing: an invalid id or value writes nothing |
| tweaks_reset | Clears everything the tools cover, as the panel's Reset does |
| tweaks_trigger | Fires an action field, e.g. { "id": "receiveContact" }. Registered only when a mounted panel or globalSections has actions |
- The tools cover every panel that's mounted (the default
<TweaksPanel>and custom renderers built onuseTweaksPanelalike), plusglobalSections. They update as panels mount and unmount. Two panels share one set of tools. - The input schemas come from your configs, so an agent sees each field's ids
and allowed values before calling. Invalid input comes back as
{ "error": "…" }naming the problem, rather than as a thrown error, because Chrome replaces a thrown error's message with a generic one. - When a call returns, the page already shows its result, including updates made by an action's handler, so an agent can read the DOM straight away.
- Nothing is registered during a server render, in a browser without
document.modelContext, or while the prop is off. The prop only exposes what the panel already does, so gate it the same way you gate the provider. - Only one provider per page should turn it on. A second one's tools clash on name and wait (you get a console warning). Once the first provider unmounts, they take over the next time one of the second provider's panels mounts or unmounts.
- If you install a WebMCP polyfill, install it before the provider mounts. The
provider looks for
document.modelContextwhen its panels change, not continuously.
This is experimental, like the spec it follows. The tool names, inputs and
outputs may change in a minor release as WebMCP settles. As of Chrome 153,
native support is behind the origin trial or --enable-features=WebMCP.
Chrome DevTools MCP
can call the tools with --categoryExperimentalWebmcp.
SSR
The panel is safe to render on the server. In React, the store goes through
useSyncExternalStore with a server snapshot that reports it as empty, so
server HTML always shows defaults and values restored from sessionStorage
can't cause a hydration mismatch. The Vue provider gets the same result its own
way: it reads the store as empty during the server render and hydration, then
shows the restored values once mounted. Neither needs configuring.
Vue
@pixelmatters/tweaks/vue matches the React entry: the same config, fields,
scenarios, provider props, panel markup, theming and behavior. Hooks become
composables, and the config type is TweaksConfigWithComposables. Everything
above applies; this section covers the differences.
Mount the provider once around your app:
<!-- App.vue -->
<script setup lang="ts">
import { TweaksProvider } from '@pixelmatters/tweaks/vue'
const dev = import.meta.env.DEV
</script>
<template>
<TweaksProvider v-if="dev" storage-key="my-app.tweaks">
<RouterView />
</TweaksProvider>
<RouterView v-else />
</template>Declare scenarios with the Vue entry's defineTweaksConfig and read them in
setup. Reads return a ComputedRef, so they're reactive and unwrap in the
template:
<script setup lang="ts">
import { defineTweaksConfig, TweaksPanel } from '@pixelmatters/tweaks/vue'
import { loadStateField } from '@pixelmatters/tweaks/scenarios'
const CONTACTS_TWEAKS = defineTweaksConfig({
id: 'contacts',
title: 'Scenarios',
sections: [{ title: 'Data state', fields: [loadStateField()] }],
})
const loadState = CONTACTS_TWEAKS.useTweak('loadState')
// ^? ComputedRef<'loaded' | 'loading' | 'empty' | 'error'>
const dev = import.meta.env.DEV
</script>
<template>
<p v-if="loadState === 'error'" role="alert">Failed to load contacts.</p>
<!-- … -->
<TweaksPanel v-if="dev" :config="CONTACTS_TWEAKS" :class-names="{ fab: 'bottom-left' }" />
</template>- Reads:
config.useTweak(id),config.useRecord(id),useTweak(id, fallback)anduseRawTweakValue(id)return aComputedRef. Typing and fallbacks work as in React. - Actions:
useTweakAction(id, handler)stays subscribed for as long as the calling component (or effect scope) is alive. - Writes:
config.useControls()anduseTweaksControls()return plain setters, same as React. - Headless:
useTweaksPanel(config)accepts a config, a ref or a getter.open,sectionsandactiveCountcome back as refs, next to the samesetOpen,setValue,fireandreset. Read each control withuseRawTweakValue(id)rather than a values map, for the same reason as in React. - Errors: the panel's boundaries catch render and setup errors inside the
card. An error thrown by your own
useTweakActionhandler when someone presses the action goes to your app'serrorHandler, the same way it would get past a React boundary.
Call composables from setup or <script setup>, since they get the provider
through inject.
Requirements
- React 19+ (with
react-dom) or Vue 3.5+, as optional peer dependencies. Install the one your app uses. - The card uses CSS anchor positioning where the browser supports it. Elsewhere it falls back to fixed insets and works the same.
@tanstack/storeis an internal dependency. It doesn't appear in the public types and may be replaced without a breaking change.
