What makes monochrome monochrome. The north stars are the
contract; the shape choices below them explain why the core
looks the way it does. Read this before touching src/.
These aren't preferences. Break any of them and it isn't monochrome any more:
- DOM is the source of truth. Every decision reads
aria-expanded,aria-selected,aria-checked,aria-disabled. There is no internal state object mirroring the DOM anywhere in the library. - Event delegation only. Listeners go on
window. Zero per-element listeners. Each component registers only the events it handles. Combined, the set is still the nine:pointerdown,pointerup,click,pointermove,keydown,scroll,resize,focusin,focusout. - Zero timers. No
setTimeout,requestAnimationFrame,queueMicrotask, debounce, or throttle in a component. Every action is synchronous within its event. The router is the one exception, and only for paint: it already awaits a fetch, and after it yields once (afterPaint, asetTimeoutqueued fromrequestAnimationFrame) so the click paints before the swap (docs/router.md). Nothing in it waits for a length of time. - Zero runtime dependencies. Shared helpers (
src/dom.ts) import nothing. Components import only those helpers. The React and Vue partsaddcopies import only their framework plus one side-effect import of their own core module (monochrome/<component>), so one component ships both markup and behavior. - Baseline 2024 browsers. The core relies on the Popover
API, the router on the Navigation API (Baseline 2026). Those
set the floors
package.jsonbrowserslistdeclares, and nothing may reach past them: an API newer than the floor is not used, however much simpler it would read, and the answer is to do without it rather than to raise the floor. Raising one is a release decision, not a convenience. No polyfills shipped, andscripts/support.tsfails the build on a reach. - One file per component. Shared helpers live in
src/dom.ts. Each component issrc/{name}.tsand registers its own window listeners.src/index.tsonly imports every component soimport "monochrome"still lights up the page. Components do not import each other.
Five architectural choices that explain the shape of the core. Each looks odd at a glance and each has a specific reason. Don't "fix" them.
DOM-as-state. Reading ARIA attrs on every event looks wasteful compared to caching in a JS object. It isn't: the cache would drift the moment a user, a framework, or devtools mutates the DOM, and tracking who owns what becomes a maintenance tax. With DOM-as-state there is exactly one truth and we never have to reconcile.
Global delegated listeners. Per-instance listeners scale with component count and require teardown on unmount. Window listeners are constant cost per component (not per instance), require zero teardown, and automatically cover dynamically-inserted DOM without re-wiring. Each file registers its own; the combined entry does not own a dispatcher.
ID prefix dispatch. We route events to handlers by checking the
prefix of target.id (mct:, mcc:, mcr:). No classes, no data
attributes, no registration table. Enum values spell out the full
component name with a trailing colon (mct:accordion:,
mct:dialog-open:), exactly as the ids appear in the DOM.
File-scope let for state. No classes, no this, no closures
passed down. Handlers share state through let and const
declared at file scope inside the hasDocument guard (menuStack,
popoverShown, tooltipShown, …). This is why
each component is one file: the state is part of that file's
mental model, and no other component reads it.
should* driver flags for cross-handler communication. The
conventional move is to thread a mutable parameter (or return a
result object) through every function the event visits, so each
layer can report "I want preventDefault", "I matched a letter",
"I'm doing a radio sweep" back up. The core skips all of that:
flags like shouldPreventDefault, shouldMatchLetter, and
shouldResetRadio live at file scope. A deep callback sets one
during event processing; the owner of that event reads it before
the cycle ends. Some flags clear at listener entry; others are
set and cleared inside one helper. Every flag dies with the
event, so there's no reentrancy to reason about. No parameter
plumbing, no return-value threading, no wrapper objects. Saves
real bytes on every function signature it removes, and it
makes the "where does this side effect come from?" question one
grep away.