# AGENTS.md Instructions for working on monochrome: an accessible, headless UI component library with no runtime dependencies. Eight components (Accordion, Collapsible, Dialog, Menu, Menubar, Popover, Tabs, Tooltip), plus an optional router and thin React and Vue wrappers. The core is framework-agnostic and works on plain HTML; import it once and every correctly- structured component on the page becomes interactive. ## North stars (non-negotiables) These aren't preferences. Break any of them and it isn't monochrome any more: 1. **DOM is the source of truth.** Every decision reads `aria-expanded`, `aria-selected`, `aria-checked`, `aria-hidden`, `aria-disabled`. There is no internal state object mirroring the DOM anywhere in the library. 2. **Event delegation only.** Seven global listeners in the core: `click`, `pointermove`, `keydown`, `scroll`, `resize`, `focusin`, `focusout`. Zero per-element listeners. 3. **Zero timers.** No `setTimeout`, `requestAnimationFrame`, `queueMicrotask`, debounce, or throttle. Every action is synchronous within its event. 4. **Zero runtime dependencies.** The core imports nothing. The wrappers import only their framework (as peer deps). 5. **Baseline 2024 browsers.** We rely on the Popover API. No polyfills shipped. 6. **The core is one file.** `src/index.ts`. Don't split it. The whole-file view is what makes the event-delegation logic reviewable. ## Why the core looks weird (and should stay weird) Six architectural choices that explain the *shape* of the library. 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-component listeners scale with component count and require teardown on unmount. Eight global listeners are constant cost, require zero teardown, and automatically cover dynamically-inserted DOM without re-wiring. **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. Prefix is the shortest string that uniquely identifies a component among the current set; grow only to disambiguate (`mct:ta` vs `mct:to` because both start with `t`), never preemptively. **Module-level `let` for state.** No classes, no `this`, no closures passed down. Handlers share state through module-scope variables (`menuPopovers`, `popoverOpen`, `tooltipShown`, …). This is why the core is one file: the state is part of the file's mental model. **`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 module scope. A deep callback sets one during event processing; the top-level listener reads and clears it at the tail. No parameter plumbing, no return-value threading, no wrapper objects. It looks unconventional because shared mutable state usually is, but every flag's lifetime is bounded by a single synchronous event cycle (cleared at the top of each listener), so there's no reentrancy to reason about. Saves real bytes on every function signature it removes, and it makes the "where does this side effect come from?" question one grep away. **Wrappers use `createElement` / `h`, not JSX / SFC.** Eliminates `react/jsx-runtime` from the React bundle and halves the Vue bundle (no SFC patch-flag machinery). Source stays framework-agnostic in style. ## Clever tricks Specific mechanisms inside the core and router. Read this section when you're debugging a particular behaviour; skip it when you just want the architecture. **`while` with sibling pointers, not `querySelectorAll`.** Every DOM walk in the core is a hand-rolled loop: `let item = root.firstElementChild; while (item) { ...; item = item.nextElementSibling }`. `querySelectorAll` would allocate a NodeList and run a selector parser for structure we already know (Accordion items are direct children of the root; Tab buttons are direct children of the List). A sibling-pointer walk costs nothing, makes iteration order explicit (single-mode Accordion needs close-before-open, Tabs toggles off-and-on in one pass), and lets a single traversal do work that a list plus follow-up would split in two. **Walk-up then walk-down for click dispatch.** The click listener walks UP from the event target (`target = target.parentElement`) until it finds a recognised ID prefix. When it lands inside a menu popover, a *second* walk runs from the original event target back UP to the popover, looking for a menuitem. Two loops, no `closest`, no selectors. The fall-through case (walk reached the document root without matching) is the outside-click detector: component routing and outside-click collapse into the same traversal. **`findAncestor` over `closest()`.** `findAncestor(el, prefix)` walks `parentElement` up checking `id.startsWith(prefix)`. `closest(".foo")` would require classes or data attributes, which is the exact shadow registry the ID-prefix scheme exists to avoid. The manual walk is fewer bytes, inlines into a single loop, and doesn't pull in the CSS selector engine. **Array-as-nullable-stack.** `menuPopovers[0]` is "is any menu open?", `menuPopovers[1]` is "is a submenu open?", `menuPopovers.pop()` closes the topmost. No `.length` check, no parallel `openMenu: HTMLElement | null` variable, no wrapper type. One array doubles as flag, stack, and cursor. **Roving-boundary sentinel.** A generic sibling walker can't distinguish "walked past the end and wrapped" from "kept going past the start". On an all-disabled list the naive walker loops forever. `rovingBoundary` remembers the first candidate the walker rejected; if we ever see it again we give up. One pointer, zero counters, zero extra passes. Cleared at the top of every `keydown` so each keypress starts with a fresh boundary. **Radio sweep reuses the navigation walker.** Activating a `menuitemradio` must clear `aria-checked` on every adjacent radio up to the group boundary. Instead of writing a dedicated sweep, `menuItemAction` sets three module flags (`shouldResetRadio`, `radioHeadDone`, `radioTailChain`) and calls the same `menuNext` used for ArrowDown. `menuRoving` notices the non-null driver state and switches into sweep mode: clear radios in the "head" half, buffer them past the wrap, flush the tail once the activated item is reached. One engine, three behaviours (plain roving, typeahead, radio sweep), selected by which module-scope flag is non-null. **Never handle Enter/Space in `keydown`.** The browser synthesises a `click` on Enter/Space for `