diff --git a/tests/blog-carousel.test.mjs b/tests/blog-carousel.test.mjs
new file mode 100644
index 00000000..adb15233
--- /dev/null
+++ b/tests/blog-carousel.test.mjs
@@ -0,0 +1,236 @@
+import assert from 'node:assert/strict';
+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';
+
+const CAROUSEL_HTML = `
+
+
+
Slide 1
+
Slide 2
+
Slide 3
+
+
+
+
+`;
+
+const PROGRESS_HTML = '';
+
+describe('blog carousel', () => {
+ let env;
+ let container;
+ let track;
+ let slides;
+ let prevButton;
+ let nextButton;
+
+ beforeEach(() => {
+ env = createBlogDomEnvironment(CAROUSEL_HTML);
+ const doc = env.document;
+ container = doc.querySelector('[data-carousel]');
+ track = container.querySelector('[data-carousel-track]');
+ slides = Array.from(track.children);
+ prevButton = container.querySelector('[data-carousel-prev]');
+ nextButton = container.querySelector('[data-carousel-next]');
+
+ env.inlineStyleGuard.guard(container);
+ env.inlineStyleGuard.guard(track);
+ env.inlineStyleGuard.guard(prevButton);
+ env.inlineStyleGuard.guard(nextButton);
+ slides.forEach((slide) => env.inlineStyleGuard.guard(slide));
+ });
+
+ afterEach(() => {
+ env.cleanup();
+ });
+
+ test('initializes slides with dynamic styling and accessibility state', () => {
+ const state = createCarousel(container, {
+ perPage: 1,
+ loop: true,
+ controls: { prev: prevButton, next: nextButton },
+ });
+
+ assert.ok(state, 'state should be created when container markup is valid');
+ assert.strictEqual(state.perPage, 1);
+ assert.strictEqual(state.slides.length, 3);
+ assert.ok(container.classList.contains('splide'));
+ assert.ok(container.classList.contains('is-initialized'));
+ assert.ok(container.classList.contains('is-active'));
+
+ const trackDynamicClasses = Array.from(track.classList).filter((className) => className.startsWith('bvds-'));
+ assert.ok(trackDynamicClasses.length >= 1, 'track should receive dynamic style classes');
+
+ for (const slide of slides) {
+ assert.ok(slide.classList.contains('splide__slide'));
+ const dynamicClasses = Array.from(slide.classList).filter((className) => className.startsWith('bvds-'));
+ assert.ok(dynamicClasses.length >= 1, 'slide should receive dynamic sizing styles');
+ }
+
+ assert.strictEqual(slides[0].getAttribute('aria-hidden'), 'false');
+ assert.strictEqual(slides[1].getAttribute('aria-hidden'), 'true');
+ assert.strictEqual(slides[2].getAttribute('aria-hidden'), 'true');
+
+ state.goTo(2);
+ assert.strictEqual(state.index, 2);
+ assert.strictEqual(slides[2].getAttribute('aria-hidden'), 'false');
+ assert.strictEqual(slides[0].getAttribute('aria-hidden'), 'true');
+
+ destroyCarousel(state);
+ });
+
+ test('control buttons advance slides and respect looping', () => {
+ const state = createCarousel(container, {
+ perPage: 1,
+ loop: true,
+ controls: { prev: prevButton, next: nextButton },
+ });
+
+ nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
+ assert.strictEqual(state.index, 1);
+ nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
+ assert.strictEqual(state.index, 2);
+ nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
+ assert.strictEqual(state.index, 0, 'looping should wrap forward navigation to the first slide');
+
+ prevButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
+ assert.strictEqual(state.index, 2, 'looping should wrap backward navigation to the last slide');
+
+ destroyCarousel(state);
+ });
+
+ test('auto play progress resets when navigation occurs', () => {
+ const state = createCarousel(container, {
+ perPage: 1,
+ controls: { prev: prevButton, next: nextButton },
+ });
+ const events = [];
+ const progress = {
+ reset: () => events.push('reset'),
+ start: (duration) => events.push(['start', duration]),
+ destroy: () => events.push('destroy'),
+ };
+ state.setProgress(progress);
+ state.autoPlayInterval = 4200;
+
+ state.goTo(1);
+ assert.deepStrictEqual(events, ['reset', ['start', 4200]]);
+
+ events.length = 0;
+ state.goTo(2, { restartProgress: false });
+ assert.deepStrictEqual(events, [], 'progress should not restart when restartProgress is false');
+
+ destroyCarousel(state);
+ });
+
+ test('destroyCarousel tears down listeners and progress state', () => {
+ const state = createCarousel(container, {
+ perPage: 1,
+ controls: { prev: prevButton, next: nextButton },
+ });
+ const progressEvents = [];
+ const progress = {
+ reset: () => progressEvents.push('reset'),
+ start: () => progressEvents.push('start'),
+ destroy: () => progressEvents.push('destroy'),
+ };
+ state.setProgress(progress);
+ state.autoPlayInterval = 2000;
+ state.goTo(1);
+
+ destroyCarousel(state);
+ assert.ok(progressEvents.includes('destroy'), 'progress.destroy should be called during teardown');
+ assert.ok(!container.classList.contains('is-initialized'));
+ assert.ok(!container.classList.contains('is-active'));
+ assert.strictEqual(state.cleanup.length, 0);
+
+ const previousIndex = state.index;
+ nextButton.dispatchEvent(new env.window.MouseEvent('click', { bubbles: true }));
+ assert.strictEqual(state.index, previousIndex, 'clicks after destroy should not change the index');
+ });
+
+ test('advanceSlide delegates to goTo with relative offsets', () => {
+ const state = createCarousel(container, { perPage: 1 });
+ const goToSpy = createSpy();
+ state.goTo = goToSpy;
+ state.index = 2;
+
+ advanceSlide(state, -1, { restartProgress: false });
+ assert.strictEqual(goToSpy.callCount, 1);
+ assert.deepStrictEqual(goToSpy.calls[0], [1, { restartProgress: false }]);
+ });
+});
+
+
+describe('progress bar', () => {
+ let env;
+ let container;
+
+ beforeEach(() => {
+ env = createBlogDomEnvironment(PROGRESS_HTML);
+ container = env.document.getElementById('progress-root');
+ env.inlineStyleGuard.guard(container);
+ });
+
+ afterEach(() => {
+ env.cleanup();
+ });
+
+ test('mountProgressBar renders structure with dynamic width styles', () => {
+ const progress = mountProgressBar(container, { initialDuration: 500, autoStart: false });
+ assert.ok(progress, 'progress instance should be returned');
+
+ const wrapper = container.querySelector('.splide__progress');
+ assert.ok(wrapper, 'wrapper element should be appended');
+ const fill = wrapper.querySelector('.splide__progress__bar');
+ assert.ok(fill, 'fill element should be created');
+
+ progress.reset();
+ const cssRules = readDynamicStyleRules(env.document);
+ assert.match(cssRules, /width:\s*0%/);
+ });
+
+ test('autoStart runs animation and triggers completion callback', () => {
+ const onComplete = createSpy();
+ const progress = mountProgressBar(container, { initialDuration: 600, autoStart: true, onComplete });
+ const wrapper = progress.element;
+ const fill = wrapper.querySelector('.splide__progress__bar');
+ assert.ok(Array.from(fill.classList).some((className) => className.startsWith('bvds-')));
+
+ env.raf.step(0);
+ env.raf.step(300);
+ assert.strictEqual(onComplete.callCount, 0);
+
+ env.raf.step(300);
+ assert.strictEqual(onComplete.callCount, 1);
+
+ const cssRules = readDynamicStyleRules(env.document);
+ assert.match(cssRules, /width:\s*100%/);
+ });
+
+ test('manual controls support stop, restart, and cleanup', () => {
+ const onComplete = createSpy();
+ const progress = mountProgressBar(container, { initialDuration: 800, onComplete });
+
+ progress.start(800);
+ env.raf.step(0);
+ env.raf.step(400);
+ progress.stop();
+ env.raf.step(600);
+ assert.strictEqual(onComplete.callCount, 0, 'stop should cancel pending completion');
+
+ progress.setOnComplete(onComplete);
+ progress.start(0);
+ assert.strictEqual(onComplete.callCount, 1, 'zero duration should fire completion immediately');
+
+ progress.destroy();
+ assert.strictEqual(container.children.length, 0, 'destroy should remove wrapper from DOM');
+ });
+});
diff --git a/tests/test-helpers/blog-dom.mjs b/tests/test-helpers/blog-dom.mjs
new file mode 100644
index 00000000..b7eefed0
--- /dev/null
+++ b/tests/test-helpers/blog-dom.mjs
@@ -0,0 +1,178 @@
+import { JSDOM } from 'jsdom';
+
+const DOM_GLOBALS = [
+ 'window',
+ 'document',
+ 'HTMLElement',
+ 'HTMLStyleElement',
+ 'Element',
+ 'Node',
+ 'Event',
+ 'MouseEvent',
+ 'CustomEvent',
+ 'KeyboardEvent',
+ 'MutationObserver',
+ 'getComputedStyle',
+];
+
+export function createSpy(implementation = () => {}) {
+ function spy(...args) {
+ spy.calls.push(args);
+ spy.callCount += 1;
+ return implementation(...args);
+ }
+ spy.calls = [];
+ spy.callCount = 0;
+ return spy;
+}
+
+export function createAnimationFrameStub() {
+ let now = 0;
+ let handle = 0;
+ const queue = new Map();
+
+ function requestAnimationFrame(callback) {
+ handle += 1;
+ const id = handle;
+ queue.set(id, callback);
+ return id;
+ }
+
+ function cancelAnimationFrame(id) {
+ queue.delete(id);
+ }
+
+ function step(elapsed = 0) {
+ now += elapsed;
+ const callbacks = Array.from(queue.entries());
+ queue.clear();
+ for (const [, callback] of callbacks) {
+ callback(now);
+ }
+ }
+
+ return {
+ requestAnimationFrame,
+ cancelAnimationFrame,
+ step,
+ get now() {
+ return now;
+ },
+ get pending() {
+ return queue.size;
+ },
+ };
+}
+
+export function installInlineStyleGuard(document) {
+ const originalCreateElement = document.createElement.bind(document);
+ const guardedElements = new WeakSet();
+
+ function guard(element) {
+ if (!element || typeof element !== 'object') {
+ return element;
+ }
+
+ if (guardedElements.has(element)) {
+ return element;
+ }
+
+ Object.defineProperty(element, 'style', {
+ get() {
+ throw new Error(
+ 'Inline style access is disabled in blog widget tests. Use applyDynamicStyles or CSS class toggles instead.',
+ );
+ },
+ set() {
+ throw new Error(
+ 'Inline style assignment is disabled in blog widget tests. Use applyDynamicStyles or CSS class toggles instead.',
+ );
+ },
+ configurable: true,
+ });
+
+ guardedElements.add(element);
+
+ if (element.children?.length) {
+ Array.from(element.children).forEach((child) => guard(child));
+ }
+
+ return element;
+ }
+
+ document.createElement = function createElementGuarded(...args) {
+ const element = originalCreateElement(...args);
+ guard(element);
+ return element;
+ };
+
+ return {
+ guard,
+ restore() {
+ document.createElement = originalCreateElement;
+ },
+ };
+}
+
+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;
+ const previousGlobals = new Map();
+
+ for (const key of DOM_GLOBALS) {
+ previousGlobals.set(key, globalThis[key]);
+ const nextValue = window[key];
+ if (typeof nextValue !== 'undefined') {
+ globalThis[key] = nextValue;
+ } else {
+ delete globalThis[key];
+ }
+ }
+
+ const inlineStyleGuard = installInlineStyleGuard(window.document);
+ const raf = createAnimationFrameStub();
+
+ previousGlobals.set('requestAnimationFrame', globalThis.requestAnimationFrame);
+ previousGlobals.set('cancelAnimationFrame', globalThis.cancelAnimationFrame);
+
+ globalThis.requestAnimationFrame = raf.requestAnimationFrame;
+ globalThis.cancelAnimationFrame = raf.cancelAnimationFrame;
+ window.requestAnimationFrame = raf.requestAnimationFrame;
+ window.cancelAnimationFrame = raf.cancelAnimationFrame;
+
+ return {
+ window,
+ document: window.document,
+ dom,
+ raf,
+ inlineStyleGuard,
+ cleanup() {
+ inlineStyleGuard.restore();
+ dom.window.close();
+
+ for (const [key, value] of previousGlobals) {
+ if (typeof value === 'undefined') {
+ delete globalThis[key];
+ } else {
+ globalThis[key] = value;
+ }
+ }
+ },
+ };
+}