chai-wind POC v0.1 — Docs

How it actually
works.

DOM scanning → string parsing → inline injection → live-watching via MutationObserver. Every part explained with diagrams and annotated code.

01 The Style Map

Before anything can be scanned or injected, chai-wind needs a source of truth: a plain JavaScript object mapping class name strings to CSS declaration strings. Everything else in the engine reads from or writes to this object.

const customStyles = { "chai-p-2": "padding: 8px", "chai-flex": "display: flex", "chai-text-lg": "font-size: 18px; line-height: 28px", // ← multi-property "chai-neo-shadow": "box-shadow: 5px 5px 0px #0a0a0a", // ... 100+ more };
Why a plain object and not a Map? Two reasons: object literals are more readable for this use case, and Object.assign() makes runtime merging trivial - which is exactly what the register() API uses to extend the style map at runtime.
Multi-property classes - A single key like "chai-text-lg" maps to multiple semicolon-separated declarations. One class, many properties. Tailwind does the same internally for spacing, typography, and layout utilities.

02 applyStyleString()

Once a class name matches, we have a CSS string like "padding-left: 16px; padding-right: 16px". Three different browser APIs can write inline styles - only one is safe.

With the right API chosen, the parsing pipeline itself is straightforward:

Why indexOf(":") instead of split(":")? CSS values can contain colons - background-image: url(https://example.com/img.png). Splitting on ":" would shatter that value into three pieces. Finding the first colon and using .slice() gives us the property name on the left and the complete value on the right, always intact.

03 processElement()

The core function that takes a single DOM node and applies all matching chai styles to it. Three early-exit guards run upfront - each has a specific, non-obvious reason.

The nodeType guard matters because the DOM tree contains many non-element nodes. Accessing .classList on a text node returns undefined, causing a silent crash.

nodeType Represents Passes guard?
1 - ELEMENT_NODE <div>, <p>, <span>, etc. YES
3 - TEXT_NODE Text content between tags NO
8 - COMMENT_NODE HTML comments <!-- ... --> NO
9 - DOCUMENT_NODE The document itself NO
11 - DOCUMENT_FRAGMENT DocumentFragment NO
classList vs className - We use el.classList.forEach() rather than parsing el.className.split(" ") manually. The classList API (DOMTokenList) handles multiple spaces, leading/trailing whitespace, and empty strings automatically. Less code, fewer edge cases.
data-chai-applied is a debugging stamp. Inspect any styled element in DevTools - you'll see data-chai-applied="chai-p-2 chai-flex ...". It's also a test-assertion hook: expect(el.dataset.chaiApplied).toContain("chai-p-4").

04 scanDOM()

The initial full-DOM pass. Uses a targeted CSS attribute selector so the browser's native selector engine - written in C++ - does the heavy filtering before a single line of JavaScript runs.

function scanDOM(root = document.body) { // [class*="chai-"] lets the native selector engine pre-filter const elements = root.querySelectorAll('[class*="chai-"]'); elements.forEach(processElement); processElement(root); // root itself is NOT in the NodeList }
querySelectorAll returns descendants only. The root element itself is never included in the NodeList. Without the explicit processElement(root) call, any element passed as root - like a freshly inserted component wrapper - would be silently skipped.

05 MutationObserver

scanDOM() runs once at initialisation. But real applications constantly mutate the DOM - React renders, Vue updates templates, plain JS inserts elements on user interaction. Any element added after the scan would miss the styling pass entirely. The MutationObserver API solves this.

The observer is configured with four options, each chosen deliberately:

observer.observe(document.body, {
  childList:      true,  // watch nodes added/removed
  subtree:        true,  // entire tree, not just direct children
  attributes:     true,  // watch attribute changes
  attributeFilter: ["class"], // only class changes - ignore id, style, data-*
});
subtree: true is critical. Without it, only direct children of document.body would be watched. Any element inside a nested component - which is the normal case - would be invisible to the observer.
attributeFilter: ["class"] is a performance guard. Without it, every style, id, data-*, or aria-* mutation would fire the callback. We only care about class changes, so we filter down to one attribute name.

The callback handles two mutation types:

mutation.type Trigger What we collect
"childList" Nodes added/removed (e.g. appendChild, innerHTML =) Each node in mutation.addedNodes that is an ELEMENT_NODE
"attributes" Class attribute changed (e.g. el.classList.add("chai-p-4")) mutation.target - the element itself

06 RAF Batching

The observer callback doesn't process nodes immediately. Instead it collects them in a Set and schedules a single requestAnimationFrame flush. This is the batch-and-drain pattern.

let _pendingNodes = new Set();
let _rafId = null;

function _flushPending() {
  _pendingNodes.forEach((node) => {
    processElement(node);
    node.querySelectorAll?.('[class*="chai-"]').forEach(processElement);
  });
  _pendingNodes.clear();
  _rafId = null;
}

// In the observer callback:
if (!_rafId) {
  _rafId = requestAnimationFrame(_flushPending); // schedule once
}
The _rafId guard ensures only one frame is ever pending at a time. Mutations keep arriving and accumulating in _pendingNodes, but no second requestAnimationFrame is scheduled until the current one fires and clears _rafId back to null.

07 Init & readyState

When chai-wind's script executes, the HTML parser may still be running - meaning document.body doesn't yet contain all its elements. The initialisation block checks document.readyState to decide whether to run immediately or wait.

if (document.readyState === "loading") {
  // Parser still running - wait for it to finish
  document.addEventListener("DOMContentLoaded", () => {
    ChaiWind.init({ debug: true });
  });
} else {
  // DOM is already available - init immediately
  ChaiWind.init({ debug: true });
}
This makes chai-wind safe to drop in anywhere - <head> without defer, end of <body>, or as a module. The readyState check removes any placement constraint.

08 Full Data Flow

Every layer in one picture - from the HTML file loading to a styled element on screen.

The four-layer architecture: data layer (style map) → parsing layer (applyStyleString) → traversal layer (processElement, scanDOM) → reactivity layer (MutationObserver + RAF). Each layer does one thing and can be understood, tested, and replaced independently.

09 Public API

Four methods are exposed on window.ChaiWind. Three are functional; one is a developer ergonomics helper.

init ChaiWind.init(options?)

Merges optional extra styles into the map via Object.assign(), then calls scanDOM() and starts the MutationObserver. Called automatically - you only call it manually if you want custom styles or need to re-init.

ChaiWind.init({
  debug: true, // log matched elements to console
  styles: { // extend the style map before first scan
    "my-card": "background:#fff; border-radius:8px"
  }
});
register ChaiWind.register(newStyles)

Adds new class mappings at runtime. After merging into customStyles, it uses [class~="cls"] to find only the elements that actually carry the new class - avoiding a full re-scan.

ChaiWind.register({
  "chai-card-elevated": `
    background-color: #fff;
    border-radius: 12px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.12);
    padding: 24px
  `
});

Elements with that class that loaded before register() was called are retroactively styled - the scan-and-match pattern is not a one-shot operation.

refresh ChaiWind.refresh()
Calls scanDOM() manually. An escape hatch for cases where DOM mutations happened through a framework's reconciler in a way the observer missed, or after a batch class update. In practice the observer handles everything, but the escape hatch is good API design.
listClasses ChaiWind.listClasses()
Calls console.table(customStyles) - renders the full style map as a sortable, searchable table in DevTools. Useful for checking what classes are registered and how they resolve.

The key insight in register() is that it targets only elements that need updating:

10 Key Takeaways

chai-wind is small, but it exercises a dense cluster of browser APIs. Building it from scratch surfaces behaviours that are easy to take for granted:

The system has four layers: a data layer (style map) → a parsing layer (applyStyleString) → a traversal layer (processElement, scanDOM) → a reactivity layer (MutationObserver + RAF). Each layer does one thing. Each can be understood, tested, and replaced independently. That separation is not accidental - it's what makes the whole thing legible.