makeover-webview 0.104.0

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser. With the build feature, writes a consumer's generated frontend files and checks the hand-written ones against them.
Documentation
// The numbers an emitter hands the stylesheet, set where a policy allows it.
//
// A bar's value, a row's depth, a track's place and a split's share are custom
// properties the rules read. A `style-src` without `'unsafe-inline'` refuses
// every style attribute, so the emitters write the declarations into
// `data-vars` instead, and this sets each one through the CSSOM, which the
// policy does not police.
//
// A classic script in the head, not deferred, on purpose: the observer is
// running before the parser reaches the body, so every element gets its
// properties as it is inserted and the first paint is already right. A deferred
// script would paint every chart flat and then grow it.
//
// Only custom properties. Applying any declaration found in markup would hand
// back exactly what the policy refused, so a name that does not start with
// `--` is skipped.
//
// It also keeps this device's density choice, for the same reason it is not
// deferred. The choice lives in the browser rather than with the app, because
// it is about what is pointing at this screen, and it is worn as an attribute
// on the root that the geometry sheet keys a block on. A picker carrying
// `data-density-choice` and no name is how a reader changes it: nothing is
// submitted, the store is written, and the page redraws at the new density.
(() => {
    "use strict";

    /** The attribute the emitters write: `makeover_webview::VARS_ATTR`. */
    const ATTR = "data-vars";

    /** `makeover_webview::DENSITY_STORE_KEY`. */
    const DENSITY_KEY = "makeover-density";
    /** `makeover_webview::DENSITY_CHOICE_ATTR`. */
    const DENSITY_CHOICE = "data-density-choice";
    /** `makeover_geometry::DENSITY_ATTRIBUTE`. */
    const DENSITY = "data-density";
    /** Every `makeover_geometry::Density::attribute_value`. */
    const DENSITIES = ["pointer", "touch"];

    /**
     * The stored choice, or "" for none.
     *
     * Storage can throw (a private window, a blocked origin) and can hold
     * anything, so both read as no choice: the page keeps what it detects.
     */
    const chosen = () => {
        try {
            const value = localStorage.getItem(DENSITY_KEY) ?? "";
            return DENSITIES.includes(value) ? value : "";
        } catch {
            return "";
        }
    };

    /** Wear a choice on the root, or hand the root back to detection. */
    const wear = (value) => {
        const root = document.documentElement;
        if (value) root.setAttribute(DENSITY, value);
        else root.removeAttribute(DENSITY);
    };

    /** Show this device's choice on a picker that has just arrived. */
    const show = (picker) => {
        picker.value = chosen();
    };

    wear(chosen());

    document.addEventListener("change", (event) => {
        const picker = event.target;
        if (!(picker instanceof Element) || !picker.hasAttribute(DENSITY_CHOICE)) return;
        const value = DENSITIES.includes(picker.value) ? picker.value : "";
        try {
            if (value) localStorage.setItem(DENSITY_KEY, value);
            else localStorage.removeItem(DENSITY_KEY);
        } catch {
            // Unstored, still worn: the choice holds for this page.
        }
        wear(value);
    });

    /** What this script set on each element, so an update can take it back. */
    const set = new WeakMap();

    /** `--a: 1; --b: 2` as pairs, keeping only custom properties. */
    const pairs = (text) => {
        const found = new Map();
        for (const part of text.split(";")) {
            const at = part.indexOf(":");
            if (at < 0) continue;
            const name = part.slice(0, at).trim();
            if (!name.startsWith("--")) continue;
            found.set(name, part.slice(at + 1).trim());
        }
        return found;
    };

    /**
     * Make the element's properties what its attribute says now.
     *
     * A morph that changes the attribute in place leaves the element, so a
     * property the new text no longer names is removed rather than kept from
     * the old one.
     */
    const apply = (el) => {
        const next = pairs(el.getAttribute(ATTR) ?? "");
        for (const name of set.get(el) ?? []) {
            if (!next.has(name)) el.style.removeProperty(name);
        }
        for (const [name, value] of next) el.style.setProperty(name, value);
        set.set(el, [...next.keys()]);
    };

    /** An inserted subtree: the node itself and everything under it. */
    const within = (node) => {
        if (node.nodeType !== Node.ELEMENT_NODE) return;
        if (node.hasAttribute(ATTR)) apply(node);
        for (const el of node.querySelectorAll(`[${ATTR}]`)) apply(el);
        if (node.hasAttribute(DENSITY_CHOICE)) show(node);
        for (const el of node.querySelectorAll(`[${DENSITY_CHOICE}]`)) show(el);
    };

    new MutationObserver((records) => {
        for (const record of records) {
            if (record.type === "attributes") apply(record.target);
            else record.addedNodes.forEach(within);
        }
    }).observe(document.documentElement, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: [ATTR],
    });

    // Whatever the parser had already written before this ran.
    within(document.documentElement);
})();