diff --git a/js/ui/styleSystem.js b/js/ui/styleSystem.js deleted file mode 100644 index 7c6dc058..00000000 --- a/js/ui/styleSystem.js +++ /dev/null @@ -1,253 +0,0 @@ -const STYLE_TAG_ID = "bitvid-style-system"; - -const sheetRegistry = new WeakMap(); -const styleCache = new Map(); -const elementClassRegistry = new WeakMap(); -const classDefinitionRegistry = new Map(); - -let classSequence = 0; - -function resolveHTMLElementConstructor(documentRef) { - return ( - documentRef?.defaultView?.HTMLElement || - globalThis.HTMLElement || - null - ); -} - -function isElementCandidate(element) { - return element && typeof element === "object"; -} - -function isHTMLElement(element, documentRef) { - const Constructor = resolveHTMLElementConstructor(documentRef); - if (!Constructor) { - return false; - } - return element instanceof Constructor; -} - -function ensureDocument(element) { - if (element && element.ownerDocument) { - return element.ownerDocument; - } - if (typeof document !== "undefined") { - return document; - } - return null; -} - -function ensureStyleSheet(documentRef) { - if (!documentRef) { - return null; - } - - if (sheetRegistry.has(documentRef)) { - return sheetRegistry.get(documentRef); - } - - const root = documentRef.documentElement || documentRef.body; - const head = documentRef.head || documentRef.getElementsByTagName("head")[0]; - if (!head && root) { - // If the document does not yet have a , create one so styles attach cleanly. - const createdHead = documentRef.createElement("head"); - documentRef.insertBefore(createdHead, root); - } - - const container = documentRef.getElementById(STYLE_TAG_ID); - const StyleElementConstructor = - documentRef.defaultView?.HTMLStyleElement || - globalThis.HTMLStyleElement || - null; - const isStyleElement = - (StyleElementConstructor && container instanceof StyleElementConstructor) || - (!StyleElementConstructor && container?.tagName === "STYLE"); - const styleElement = isStyleElement - ? container - : documentRef.createElement("style"); - - styleElement.type = "text/css"; - styleElement.id = STYLE_TAG_ID; - - if (!styleElement.parentNode) { - (documentRef.head || documentRef.documentElement)?.appendChild(styleElement); - } - - const sheet = styleElement.sheet; - sheetRegistry.set(documentRef, sheet); - return sheet; -} - -function toCssPropertyName(property) { - if (!property) { - return ""; - } - if (property.startsWith("--")) { - return property; - } - return property - .replace(/([A-Z])/g, "-$1") - .replace(/_/g, "-") - .toLowerCase(); -} - -function serializeStyleObject(styleObject) { - return Object.keys(styleObject) - .sort() - .map((key) => `${key}:${String(styleObject[key])}`) - .join(";"); -} - -function createCssRule(styleObject) { - return Object.keys(styleObject) - .sort() - .map((key) => `${toCssPropertyName(key)}: ${styleObject[key]};`) - .join(" "); -} - -function getSlotKey(slot) { - if (!slot) { - return "default"; - } - return String(slot); -} - -export function applyDynamicStyles(element, styleObject = {}, options = {}) { - if (!isElementCandidate(element)) { - return null; - } - - const doc = ensureDocument(element); - if (!doc || !isHTMLElement(element, doc)) { - return null; - } - - const entries = Object.entries(styleObject).filter(([, value]) => value !== null && value !== undefined); - if (!entries.length) { - return null; - } - - const normalized = entries.reduce((acc, [key, value]) => { - acc[key] = value; - return acc; - }, {}); - - const sheet = ensureStyleSheet(doc); - if (!sheet) { - return null; - } - - const cacheKey = serializeStyleObject(normalized); - let className = styleCache.get(cacheKey); - if (!className) { - classSequence += 1; - className = `bvds-${classSequence.toString(36)}`; - const cssRule = createCssRule(normalized); - try { - sheet.insertRule(`.${className} { ${cssRule} }`, sheet.cssRules.length); - } catch (error) { - console.warn("Failed to insert dynamic style", { cssRule, error }); - return null; - } - styleCache.set(cacheKey, className); - classDefinitionRegistry.set(className, { ...normalized }); - } - - let slotRegistry = elementClassRegistry.get(element); - if (!slotRegistry) { - slotRegistry = new Map(); - elementClassRegistry.set(element, slotRegistry); - } - - const slot = getSlotKey(options.slot); - const previousClass = slotRegistry.get(slot); - if (previousClass && previousClass !== className) { - element.classList.remove(previousClass); - } - - if (!element.classList.contains(className)) { - element.classList.add(className); - } - - slotRegistry.set(slot, className); - return className; -} - -export function removeDynamicStyles(element, options = {}) { - if (!isElementCandidate(element)) { - return; - } - - const doc = ensureDocument(element); - if (!doc || !isHTMLElement(element, doc)) { - return; - } - - const slot = getSlotKey(options.slot); - const slotRegistry = elementClassRegistry.get(element); - if (!slotRegistry) { - return; - } - - const className = slotRegistry.get(slot); - if (className) { - element.classList.remove(className); - slotRegistry.delete(slot); - } - - if (!slotRegistry.size) { - elementClassRegistry.delete(element); - } -} - -export function clearAllDynamicStyles(element) { - if (!isElementCandidate(element)) { - return; - } - - const doc = ensureDocument(element); - if (!doc || !isHTMLElement(element, doc)) { - return; - } - - const slotRegistry = elementClassRegistry.get(element); - if (!slotRegistry) { - return; - } - - for (const className of slotRegistry.values()) { - element.classList.remove(className); - } - - elementClassRegistry.delete(element); -} - -export function getAppliedDynamicStyles(element, options = {}) { - if (!isElementCandidate(element)) { - return null; - } - - const doc = ensureDocument(element); - if (!doc || !isHTMLElement(element, doc)) { - return null; - } - - const slot = getSlotKey(options.slot); - const slotRegistry = elementClassRegistry.get(element); - if (!slotRegistry) { - return null; - } - - const className = slotRegistry.get(slot); - if (!className) { - return null; - } - - const definition = classDefinitionRegistry.get(className); - if (!definition) { - return null; - } - - return { ...definition }; -} - diff --git a/scripts/check-inline-styles.mjs b/scripts/check-inline-styles.mjs index 6e781e40..deef3ac5 100644 --- a/scripts/check-inline-styles.mjs +++ b/scripts/check-inline-styles.mjs @@ -32,7 +32,6 @@ const DIST_SCAN_PREFIXES = new Set([ const DIST_SCAN_PREFIXES_ARRAY = [...DIST_SCAN_PREFIXES]; const IGNORED_FILES = new Set([ - "js/ui/styleSystem.js", "css/tailwind.generated.css", "js/webtorrent.min.js", "js/webtorrent.min.js.map", @@ -45,6 +44,7 @@ const VIOLATION_ALLOWLIST = new Map([ "torrent/dist/beacon.vendor.js", new Set(["Direct .style usage", "style.cssText usage"]), ], + ["js/ui/utils/positionFloatingPanel.js", new Set(["Direct .style usage"])], ]); const TEXT_EXTENSIONS = new Set([ @@ -220,9 +220,7 @@ async function main() { `${violation.file}:${violation.line} — ${violation.label}\n ${violation.snippet}\n` ); } - console.error( - "Inline styles are disallowed. Move styles into tokens, CSS, or styleSystem." - ); + console.error("Inline styles are disallowed. Move styles into tokens or CSS."); process.exitCode = 1; return; } diff --git a/tests/blog-carousel.test.mjs b/tests/blog-carousel.test.mjs index 89116210..d4719b5e 100644 --- a/tests/blog-carousel.test.mjs +++ b/tests/blog-carousel.test.mjs @@ -3,11 +3,16 @@ import { beforeEach, afterEach, describe, test } from 'node:test'; import { advanceSlide, createCarousel, destroyCarousel } from '../js/blog/carousel.js'; import { mountProgressBar } from '../js/blog/progressBar.js'; -import { - createBlogDomEnvironment, - createSpy, - readDynamicStyleRules, -} from './test-helpers/blog-dom.mjs'; +import { createBlogDomEnvironment, createSpy } from './test-helpers/blog-dom.mjs'; + +function assertHasClasses(element, classes, messagePrefix = 'element') { + classes.forEach((className) => { + assert.ok( + element.classList.contains(className), + `${messagePrefix} should include the “${className}” class`, + ); + }); +} function assertNoInlineStyleAttributes(elements, messagePrefix = 'element') { elements.forEach((element, index) => { @@ -208,8 +213,12 @@ describe('progress bar', () => { const wrapper = container.querySelector('.blog-carousel__progress'); assert.ok(wrapper, 'wrapper element should be appended'); + assertHasClasses(wrapper, ['blog-carousel__progress', 'blog-progress', 'mt-2'], 'progress wrapper'); + assert.strictEqual(wrapper.dataset.carouselProgress, 'true'); + assert.strictEqual(wrapper.getAttribute('data-carousel-progress'), 'true'); const meter = wrapper.querySelector('progress.progress--blog'); assert.ok(meter, 'progress meter should be created'); + assertHasClasses(meter, ['progress', 'progress--blog'], 'progress meter'); assert.strictEqual(meter.max, 100, 'progress meter should expose a 0-100 range'); assert.strictEqual(meter.value, 0, 'progress meter should start at zero'); assert.strictEqual(meter.dataset.state, 'idle'); @@ -220,12 +229,12 @@ describe('progress bar', () => { 'Carousel progress 0% complete', 'progress meter should emit accessible status text', ); + assert.strictEqual(meter.getAttribute('data-progress-meter'), 'true'); + assert.strictEqual(meter.dataset.progressMeter, 'true'); + assert.strictEqual(meter.getAttribute('data-variant'), 'blog'); progress.reset(); - assert.strictEqual(readDynamicStyleRules(env.document), '', 'no dynamic style rules should be injected'); - assert.strictEqual(env.document.getElementById('bitvid-style-system'), null); - assertNoInlineStyleAttributes([container, wrapper, meter], 'progress element'); }); @@ -249,9 +258,6 @@ describe('progress bar', () => { assert.strictEqual(meter.value, 100); assert.strictEqual(meter.dataset.state, 'complete'); assert.strictEqual(meter.dataset.progress, '100'); - - assert.strictEqual(readDynamicStyleRules(env.document), '', 'no dynamic style rules should be injected'); - assert.strictEqual(env.document.getElementById('bitvid-style-system'), null); }); test('manual controls support stop, restart, and cleanup', () => { @@ -281,9 +287,11 @@ describe('progress bar', () => { progress.destroy(); assert.strictEqual(container.children.length, 0, 'destroy should remove wrapper from DOM'); - - assert.strictEqual(readDynamicStyleRules(env.document), '', 'no dynamic style rules should be injected'); - assert.strictEqual(env.document.getElementById('bitvid-style-system'), null); + assert.strictEqual( + container.querySelector('[data-carousel-progress]'), + null, + 'destroy should remove declarative progress markup', + ); assertNoInlineStyleAttributes([container], 'progress container'); }); }); diff --git a/tests/media-loader.test.mjs b/tests/media-loader.test.mjs index 8bf7dcc7..2e79675a 100644 --- a/tests/media-loader.test.mjs +++ b/tests/media-loader.test.mjs @@ -188,8 +188,8 @@ test("MediaLoader clears unsupported lazy targets without inline styles", async ]); assert.equal( - div.style.backgroundImage, - "", + div.hasAttribute("style"), + false, "background image styles should not be injected at runtime", ); assert.ok(!observer.observed.has(div)); diff --git a/tests/position-floating-panel.test.mjs b/tests/position-floating-panel.test.mjs index 09b56170..ae3c8b6a 100644 --- a/tests/position-floating-panel.test.mjs +++ b/tests/position-floating-panel.test.mjs @@ -3,6 +3,14 @@ import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; import positionFloatingPanel from "../js/ui/utils/positionFloatingPanel.js"; +function getInlineStyleValue(element, property) { + const styleAttr = element.getAttribute("style") || ""; + const escapedProperty = property.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + const pattern = new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i"); + const match = styleAttr.match(pattern); + return match ? match[1].trim() : ""; +} + test("flips to top when bottom placement collides with viewport", () => { const dom = new JSDOM( ` @@ -54,9 +62,9 @@ test("flips to top when bottom placement collides with viewport", () => { positioner.update(); assert.equal(panel.dataset.floatingPlacement, "top"); - assert.equal(panel.style.position, "fixed"); - assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "132px"); - assert.equal(panel.style.getPropertyValue("--floating-fallback-left"), "100px"); + assert.equal(getInlineStyleValue(panel, "position"), "fixed"); + assert.equal(getInlineStyleValue(panel, "--floating-fallback-top"), "132px"); + assert.equal(getInlineStyleValue(panel, "--floating-fallback-left"), "100px"); positioner.destroy(); assert.equal(panel.dataset.floatingPanel, undefined); @@ -118,7 +126,7 @@ test("respects RTL alignment and clamps within the viewport", () => { // In RTL mode, start alignment hugs the right edge of the trigger. assert.equal(panel.dataset.floatingPlacement, "bottom"); assert.equal(panel.dataset.floatingDir, "rtl"); - assert.equal(panel.style.getPropertyValue("--floating-fallback-left"), "140px"); + assert.equal(getInlineStyleValue(panel, "--floating-fallback-left"), "140px"); positioner.destroy(); }); @@ -182,12 +190,12 @@ test("updates when scroll containers move the trigger", async () => { panel.dataset.state = "open"; positioner.update(); - assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "152px"); + assert.equal(getInlineStyleValue(panel, "--floating-fallback-top"), "152px"); assert.ok(scrollHandler, "scroll listener should be registered"); anchorTop = 60; scrollHandler?.({ type: "scroll" }); - assert.equal(panel.style.getPropertyValue("--floating-fallback-top"), "112px"); + assert.equal(getInlineStyleValue(panel, "--floating-fallback-top"), "112px"); positioner.destroy(); window.addEventListener = originalAddEventListener; diff --git a/tests/test-helpers/blog-dom.mjs b/tests/test-helpers/blog-dom.mjs index 152c47ae..d23893e3 100644 --- a/tests/test-helpers/blog-dom.mjs +++ b/tests/test-helpers/blog-dom.mjs @@ -157,22 +157,6 @@ export function installInlineStyleGuard(document) { }; } -export function readDynamicStyleRules(document) { - const styleElement = document.getElementById('bitvid-style-system'); - const sheet = styleElement?.sheet; - if (!sheet) { - return ''; - } - - try { - return Array.from(sheet.cssRules) - .map((rule) => rule.cssText) - .join('\n'); - } catch (error) { - return ''; - } -} - export function createBlogDomEnvironment(html) { const dom = new JSDOM(html, { url: 'https://example.com' }); const { window } = dom;