Raw Source
SNAD / Business Central - BLISS

// ==UserScript==
// @name         Business Central - BLISS
// @namespace    http://tampermonkey.net/
// @version      0.12.1
// @description  Basic Line ID for Swift Scanning
// @author       SNAD
// @match        https://businesscentral.dynamics.com/*
// @match        https://*.businesscentral.dynamics.com/*
// @updateURL    https://openuserjs.org/meta/SNAD/Business_Central_-_BLISS_-_Basic_Line_ID_for_Swift_Scanning.meta.js
// @downloadURL  https://openuserjs.org/install/SNAD/Business_Central_-_BLISS_-_Basic_Line_ID_for_Swift_Scanning.user.js
// @copyright    2026, SNAD (https://openuserjs.org/users/SNAD)
// @license      GPL-3.0-or-later
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const ATTENTION_SELECTOR = '.ms-nav-attention-styleview, [class*="style-attention"]';

    // v0.11 painted every selected row by walking its entire subtree in JS and writing
    // several `!important` inline styles onto EVERY descendant element, every single time
    // the mutation observer fired. On heavy, highly reactive pages (Hardware Configurator)
    // that observer can fire very often, and each firing did O(subtree size) work with no
    // debouncing - the cost of our own re-paint compounded with however busy the page
    // already was. This version does the exact same visual result with a single injected
    // stylesheet: JS only toggles a class + data attribute on the row (O(1) per row), and
    // lets the browser's CSS engine cascade the color down instead of walking the DOM by
    // hand. The gradient-background + color-scheme hint (needed so Chrome's own auto-dark
    // repaint doesn't re-invert colors we already picked) and the two hand-tuned palettes
    // are unchanged from v0.11 - only how they get applied changed.
    const STYLE_ID = 'bliss-style';
    if (!document.getElementById(STYLE_ID)) {
        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = `
            :root {
                --bliss-normal-bg: #b2e9ec;
                --bliss-red-bg: #ecb2c0;
                --bliss-normal-text: #000000;
            }
            @media (prefers-color-scheme: dark) {
                :root {
                    --bliss-normal-bg: #0f3d42;
                    --bliss-red-bg: #42121d;
                    --bliss-normal-text: #e8e8e8;
                }
            }
            tr.bliss-row[data-bliss] { box-shadow: none !important; }
            tr.bliss-row[data-bliss="normal"] { --bliss-bg: var(--bliss-normal-bg); }
            tr.bliss-row[data-bliss="red"]    { --bliss-bg: var(--bliss-red-bg); }

            tr.bliss-row[data-bliss],
            tr.bliss-row[data-bliss] td:not(.grid-selection-column),
            tr.bliss-row[data-bliss] td:not(.grid-selection-column) * {
                background-image: linear-gradient(var(--bliss-bg), var(--bliss-bg)) !important;
                background-color: var(--bliss-bg) !important;
                color-scheme: light !important;
            }
            tr.bliss-row[data-bliss] td.grid-selection-column {
                background: transparent !important;
                background-image: none !important;
            }
            /* One consistent text color across the row, except genuinely-flagged attention
               elements (and their descendants), which keep BC's native color. Each :not()
               clause must stay on the same line as the selector it modifies - CSS treats
               any whitespace between compound selectors as a descendant combinator. */
            tr.bliss-row[data-bliss],
            tr.bliss-row[data-bliss] td:not(.grid-selection-column),
            tr.bliss-row[data-bliss] td:not(.grid-selection-column) *:not(.ms-nav-attention-styleview):not([class*="style-attention"]):not(.ms-nav-attention-styleview *):not([class*="style-attention"] *) {
                color: var(--bliss-normal-text) !important;
            }
        `;
        (document.head || document.documentElement).appendChild(style);
    }

    function isAttentionRow(row) {
        return row.querySelector(ATTENTION_SELECTOR) !== null;
    }

    let lastSelectedRows = new Set();

    function forceBlueHighlights() {
        const currentlySelected = new Set();

        document.querySelectorAll('tbody tr[role="row"][aria-selected="true"]').forEach(row => {
            const rowKey = row.getAttribute('rowkey') || row.getAttribute('aria-rowindex') || 'unknown';
            currentlySelected.add(rowKey);
            row.classList.add('bliss-row');
            row.dataset.bliss = isAttentionRow(row) ? 'red' : 'normal';
        });

        // Cleanup deselected rows
        lastSelectedRows.forEach(oldKey => {
            if (!currentlySelected.has(oldKey)) {
                document.querySelectorAll('tr.bliss-row').forEach(row => {
                    const rowKey = row.getAttribute('rowkey') || row.getAttribute('aria-rowindex');
                    if (rowKey === oldKey) {
                        row.classList.remove('bliss-row');
                        delete row.dataset.bliss;
                    }
                });
            }
        });

        lastSelectedRows = currentlySelected;
    }

    // Coalesce bursts of mutations into at most one re-render per animation frame, so a
    // page that's already busy (lots of mutations firing close together) doesn't also get
    // hit with a pile of synchronous re-renders stacking up on top of that.
    let rafScheduled = false;
    function scheduleForceHighlights() {
        if (rafScheduled) return;
        rafScheduled = true;
        requestAnimationFrame(() => {
            rafScheduled = false;
            forceBlueHighlights();
        });
    }

    const observer = new MutationObserver((mutations) => {
        if (mutations.some(m => m.attributeName === 'aria-selected' || m.addedNodes.length)) {
            scheduleForceHighlights();
        }
    });

    function init() {
        observer.observe(document.documentElement, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['aria-selected', 'class']
        });
        forceBlueHighlights();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    window.addEventListener('popstate', forceBlueHighlights);
    const originalPush = history.pushState;
    history.pushState = function (...args) {
        originalPush.apply(this, args);
        setTimeout(forceBlueHighlights, 200);
    };

    console.log('BLISS ACTIVE: Selected lines now highlighted');
})();