From ee279c5e4e63b3ccb2a7e04dafcb26a0973a28fa Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Tue, 7 Jul 2026 11:14:59 -0500 Subject: [PATCH 01/10] fix: Fixes issue of double announcement in NVDA --- .../tabs_controller.js | 375 ++++++++++++++---- .../controllers/tabs_controller.test.js | 125 ++++++ 2 files changed, 421 insertions(+), 79 deletions(-) diff --git a/app/assets/javascripts/pathogen_view_components/tabs_controller.js b/app/assets/javascripts/pathogen_view_components/tabs_controller.js index 65d81e08..c1976d63 100644 --- a/app/assets/javascripts/pathogen_view_components/tabs_controller.js +++ b/app/assets/javascripts/pathogen_view_components/tabs_controller.js @@ -85,6 +85,20 @@ export default class extends Controller { */ #hashUpdateTimeout = null; + /** + * Index of the currently selected tab + * @type {number|null} + * @private + */ + #selectedIndex = null; + + /** + * Private field for storing the panel update timeout + * @type {number|null} + * @private + */ + #panelUpdateTimeout = null; + /** * Initializes the controller when it connects to the DOM * Sets up ARIA relationships and selects the default tab. @@ -130,7 +144,9 @@ export default class extends Controller { // Select the initial tab // Only update URL if the selected tab isn't already the current tab const validatedIndex = this.#validateDefaultIndex(initialIndex); - this.#selectTabByIndex(validatedIndex, shouldUpdateUrl, history.replaceState); + this.#applyTabSelection(validatedIndex, shouldUpdateUrl, history.replaceState, { + deferPanels: true, + }); } catch (error) { console.error("[pathogen--tabs] Error during initialization:", error); } @@ -153,6 +169,10 @@ export default class extends Controller { return; } + if (this.#selectedIndex === tabIndex && this.#isTabSelectionSynced(tabIndex)) { + return; + } + this.#selectTabByIndex(tabIndex); } catch (error) { console.error("[pathogen--tabs] Error selecting tab:", error); @@ -221,6 +241,11 @@ export default class extends Controller { this.#hashUpdateTimeout = null; } + if (this.#panelUpdateTimeout) { + clearTimeout(this.#panelUpdateTimeout); + this.#panelUpdateTimeout = null; + } + // Clear cached DOM references this.#tablist = null; @@ -292,13 +317,6 @@ export default class extends Controller { if (panel && panel.id) { tab.setAttribute("aria-controls", panel.id); } - - // Initial aria-selected (will be updated by selectTabByIndex) - tab.setAttribute("aria-selected", "false"); - - // Initial tabindex (will be updated by selectTabByIndex) - tab.tabIndex = -1; - tab.dataset.state = "inactive"; }); this.panelTargets.forEach((panel, index) => { @@ -313,86 +331,273 @@ export default class extends Controller { if (tab && tab.id) { panel.setAttribute("aria-labelledby", tab.id); } + }); + } + + /** + * Returns whether the given tab index can be selected + * @private + * @param {number} index - The tab index + * @returns {boolean} + */ + #canSelectIndex(index) { + if (!this.hasTabTarget || !this.hasPanelTarget) { + return false; + } + + return index >= 0 && index < this.tabTargets.length; + } - // Initial hidden state (will be updated by selectTabByIndex) - panel.setAttribute("aria-hidden", "true"); - panel.hidden = true; - panel.dataset.state = "inactive"; + /** + * Returns whether panel visibility already matches the given index + * @private + * @param {number} index - The tab index + * @returns {boolean} + */ + #arePanelsSynced(index) { + if (!this.#canSelectIndex(index)) { + return false; + } + + return this.panelTargets.every((panel, i) => { + if (!panel) return false; + + const isVisible = i === index; + const hiddenValue = String(!isVisible); + const stateValue = isVisible ? "active" : "inactive"; + + return ( + panel.hidden === !isVisible && + panel.getAttribute("aria-hidden") === hiddenValue && + panel.dataset.state === stateValue + ); }); } /** - * Selects a tab by index + * Returns whether tab and panel state already match the given index + * @private + * @param {number} index - The tab index + * @returns {boolean} + */ + #isSelectionSynced(index) { + return this.#isTabSelectionSynced(index) && this.#arePanelsSynced(index); + } + + /** + * Updates tab selection state synchronously * - * This method handles both tab selection state and panel visibility. - * When a panel becomes visible (`hidden` becomes false), any Turbo Frames - * with loading="lazy" inside will automatically fetch their content. + * aria-controls is temporarily removed during updates so NVDA does not + * re-announce tabs when aria-selected changes on a focused tab. * - * Turbo Frame Integration: - * - Removing the `hidden` attribute triggers Turbo's lazy loading mechanism - * - Turbo automatically fetches frame content when it becomes visible - * - Once loaded, Turbo caches the content (no refetch on revisit) - * - No explicit fetch() or JavaScript handling needed + * @see https://github.com/nvaccess/nvda/issues/18794 * * @private * @param {number} index - The tab index to select - * @param {boolean} updateUrl - Whether to update the URL hash (default: true) - * @param {Function} updateMethod - The history method to use (default: history.pushState) * @returns {void} */ - #selectTabByIndex(index, updateUrl = true, updateMethod = history.pushState) { - setTimeout(() => { - // Defensive checks for morph scenarios - if (!this.hasTabTarget || !this.hasPanelTarget) { - return; - } + #updateTabs(index) { + const restoredControls = this.tabTargets.map((tab) => { + if (!tab) return null; - if (index < 0 || index >= this.tabTargets.length) { - return; + const controls = tab.getAttribute("aria-controls"); + if (controls) { + tab.removeAttribute("aria-controls"); } - // Update all tabs + return controls; + }); + + try { this.tabTargets.forEach((tab, i) => { - if (!tab) return; // Skip if tab doesn't exist + if (!tab) return; const isSelected = i === index; + const selectedValue = String(isSelected); + const stateValue = isSelected ? "active" : "inactive"; + const tabIndexValue = isSelected ? 0 : -1; + + if (tab.getAttribute("aria-selected") !== selectedValue) { + tab.setAttribute("aria-selected", selectedValue); + } - // Update ARIA/state attributes - tab.setAttribute("aria-selected", String(isSelected)); - tab.dataset.state = isSelected ? "active" : "inactive"; + if (tab.dataset.state !== stateValue) { + tab.dataset.state = stateValue; + } - // Update roving tabindex - tab.tabIndex = isSelected ? 0 : -1; + if (tab.tabIndex !== tabIndexValue) { + tab.tabIndex = tabIndexValue; + } }); + } finally { + this.tabTargets.forEach((tab, i) => { + const controls = restoredControls[i]; + if (tab && controls) { + tab.setAttribute("aria-controls", controls); + } + }); + } + } - // Update all panels - this.panelTargets.forEach((panel, i) => { - if (!panel) return; // Skip if panel doesn't exist + /** + * Updates panel visibility for the selected tab + * + * When a panel becomes visible (`hidden` becomes false), any Turbo Frames + * with loading="lazy" inside will automatically fetch their content. + * + * @private + * @param {number} index - The tab index to show + * @returns {void} + */ + #updatePanels(index) { + this.panelTargets.forEach((panel, i) => { + if (!panel) return; - const isVisible = i === index; + const isVisible = i === index; + const hiddenValue = String(!isVisible); + const stateValue = isVisible ? "active" : "inactive"; - // Update visibility via native hidden attribute so Turbo can detect - // when lazy frames become visible. + if (panel.hidden !== !isVisible) { panel.hidden = !isVisible; + } - // Update ARIA hidden state - panel.setAttribute("aria-hidden", String(!isVisible)); - panel.dataset.state = isVisible ? "active" : "inactive"; - }); + if (panel.getAttribute("aria-hidden") !== hiddenValue) { + panel.setAttribute("aria-hidden", hiddenValue); + } + + if (panel.dataset.state !== stateValue) { + panel.dataset.state = stateValue; + } + }); + } + + /** + * Applies tab and panel selection state synchronously + * + * @private + * @param {number} index - The tab index to select + * @param {boolean} updateUrl - Whether to update the URL hash + * @param {Function} updateMethod - The history method to use + * @returns {void} + */ + #syncPanelsAndUrl(index, updateUrl, updateMethod) { + this.#updatePanels(index); + + if (this.syncUrlValue && updateUrl) { + this.#updateUrlHash(index, updateMethod); + } + } + + /** + * Defers panel visibility and optional URL updates + * + * @private + * @param {number} index - The tab index to show + * @param {boolean} updateUrl - Whether to update the URL hash + * @param {Function} updateMethod - The history method to use + * @returns {void} + */ + #deferPanelUpdate(index, updateUrl, updateMethod) { + if (this.#panelUpdateTimeout) { + clearTimeout(this.#panelUpdateTimeout); + } + + this.#panelUpdateTimeout = setTimeout(() => { + this.#panelUpdateTimeout = null; + + if (!this.#canSelectIndex(index) || this.#selectedIndex !== index) { + return; + } + + if (!this.#arePanelsSynced(index)) { + this.#updatePanels(index); + } - // Update URL hash if sync is enabled if (this.syncUrlValue && updateUrl) { this.#updateUrlHash(index, updateMethod); } - - // Turbo Frame lazy loading happens automatically here: - // If the newly visible panel contains a , - // Turbo will fetch the content immediately after the panel becomes visible. - // The frame's fallback content (loading spinner) displays during fetch, - // then morphs into the loaded content seamlessly. }, 20); } + /** + * Applies tab selection and optionally defers panel visibility + * + * @private + * @param {number} index - The tab index to select + * @param {boolean} updateUrl - Whether to update the URL hash + * @param {Function} updateMethod - The history method to use + * @param {Object} options - Additional options + * @param {boolean} options.deferPanels - Defer panel visibility updates + * @returns {void} + */ + #applyTabSelection(index, updateUrl = true, updateMethod = history.pushState, { deferPanels = false } = {}) { + if (!this.#canSelectIndex(index)) { + return; + } + + if (this.#selectedIndex === index && this.#isTabSelectionSynced(index)) { + if (!this.#arePanelsSynced(index)) { + if (deferPanels) { + this.#deferPanelUpdate(index, updateUrl, updateMethod); + } else { + this.#syncPanelsAndUrl(index, updateUrl, updateMethod); + } + } else if (this.syncUrlValue && updateUrl) { + this.#updateUrlHash(index, updateMethod); + } + + return; + } + + this.#selectedIndex = index; + this.#updateTabs(index); + + if (deferPanels) { + this.#deferPanelUpdate(index, updateUrl, updateMethod); + } else { + this.#syncPanelsAndUrl(index, updateUrl, updateMethod); + } + } + + /** + * Selects a tab by index + * + * @private + * @param {number} index - The tab index to select + * @param {boolean} updateUrl - Whether to update the URL hash (default: true) + * @param {Function} updateMethod - The history method to use (default: history.pushState) + * @param {Object} options - Additional options + * @param {boolean} options.deferPanels - Defer panel visibility updates + * @returns {void} + */ + #selectTabByIndex(index, updateUrl = true, updateMethod = history.pushState, { deferPanels = true } = {}) { + if (!this.#canSelectIndex(index)) { + return; + } + + if (this.#selectedIndex === index && this.#isSelectionSynced(index)) { + return; + } + + this.#applyTabSelection(index, updateUrl, updateMethod, { deferPanels }); + } + + /** + * Returns whether tab roving state already matches the given index + * @private + * @param {number} index - The tab index + * @returns {boolean} + */ + #isTabSelectionSynced(index) { + if (!this.#canSelectIndex(index)) { + return false; + } + + const tab = this.tabTargets[index]; + + return tab?.getAttribute("aria-selected") === "true" && tab?.dataset.state === "active" && tab?.tabIndex === 0; + } + /** * Navigates to the previous tab (with wrap-around) * @private @@ -442,17 +647,24 @@ export default class extends Controller { * @returns {void} */ #focusAndSelectTab(index) { - if (index < 0 || index >= this.tabTargets.length) { + if (!this.#canSelectIndex(index)) { return; } const tab = this.tabTargets[index]; - // Move focus to the tab - tab.focus(); + if (this.#selectedIndex === index && document.activeElement === tab && this.#isTabSelectionSynced(index)) { + return; + } + + // Apply selection before focus so the focus announcement includes the final + // selected state. aria-controls is stripped during the attribute batch so NVDA + // does not also announce aria-selected changes separately. + this.#applyTabSelection(index, true, history.pushState, { deferPanels: true }); - // Select the tab (automatic activation) - this.#selectTabByIndex(index); + if (document.activeElement !== tab) { + tab.focus({ preventScroll: true }); + } } /** @@ -569,12 +781,14 @@ export default class extends Controller { #handleHashChange() { try { const hashIndex = this.#getTabIndexFromHash(); - if (hashIndex !== -1) { - // Use replaceState when hash is not present, to avoid creating additional history entries. - // (e.g. on initialization hash is not present and is set on initial connect, - // we don't want two history entries in this case). - this.#selectTabByIndex(hashIndex, true, history.replaceState); + if (hashIndex === -1 || this.#isSelectionSynced(hashIndex)) { + return; } + + // Use replaceState when hash is not present, to avoid creating additional history entries. + // (e.g. on initialization hash is not present and is set on initial connect, + // we don't want two history entries in this case). + this.#selectTabByIndex(hashIndex, true, history.replaceState); } catch (error) { console.error("[pathogen--tabs] Error handling hash change:", error); } @@ -603,23 +817,26 @@ export default class extends Controller { return; } - // Restore tab selection from URL hash const hashIndex = this.#getTabIndexFromHash(); - if (hashIndex !== -1) { - // Don't update URL again - we're restoring from it - // Use replaceState since we're restoring state, not creating new history - this.#selectTabByIndex(hashIndex, true, history.replaceState); - } else { - // No hash found, use default index - // Use replaceState to avoid creating history entry during restoration - const validatedIndex = this.#validateDefaultIndex(this.defaultIndexValue); - this.#selectTabByIndex(validatedIndex, true, history.replaceState); + const targetIndex = + hashIndex !== -1 ? hashIndex : (this.#selectedIndex ?? this.#validateDefaultIndex(this.defaultIndexValue)); + + if (this.#isTabSelectionSynced(targetIndex)) { + if (!this.#arePanelsSynced(targetIndex)) { + this.#updatePanels(targetIndex); + } + + return; } - // Note: We don't reload frames here because: - // 1. Frames with refresh="morph" are morphed during page morph (already translated) - // 2. Reloading causes untranslated content to flash while frame fetches new content - // 3. LocalTime processes morphed frame content via turbo:render event + // Tab attributes were reset by a morph — restore without touching panels + // when they already match. + this.#selectedIndex = targetIndex; + this.#updateTabs(targetIndex); + + if (!this.#arePanelsSynced(targetIndex)) { + this.#updatePanels(targetIndex); + } } catch (error) { console.error("[pathogen--tabs] Error handling turbo render:", error); } diff --git a/test/javascript/controllers/tabs_controller.test.js b/test/javascript/controllers/tabs_controller.test.js index 7ea505ab..a776b6f4 100644 --- a/test/javascript/controllers/tabs_controller.test.js +++ b/test/javascript/controllers/tabs_controller.test.js @@ -114,6 +114,131 @@ describe("tabs_controller", () => { expect(panelB.dataset.state).toBe("active"); }); + it("defers panel visibility during keyboard navigation", async () => { + document.body.innerHTML = ` +
+ +
Panel A
+
Panel B
+
+ `; + + await waitForTabsUpdate(); + + const [tabA, tabB] = document.querySelectorAll('[data-pathogen--tabs-target="tab"]'); + const [, panelB] = document.querySelectorAll('[data-pathogen--tabs-target="panel"]'); + let ariaSelectedUpdates = 0; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === "aria-selected" && mutation.target === tabB) { + ariaSelectedUpdates += 1; + } + }); + }); + + observer.observe(tabB, { attributes: true, attributeFilter: ["aria-selected"] }); + + tabA.focus(); + dispatchKey(tabA, "ArrowRight"); + await Promise.resolve(); + + observer.disconnect(); + + expect(document.activeElement).toBe(tabB); + expect(tabB.getAttribute("aria-selected")).toBe("true"); + expect(tabB.getAttribute("aria-controls")).toBe("panel-b"); + expect(ariaSelectedUpdates).toBe(1); + + expect(panelB.hidden).toBe(true); + + await waitForTabsUpdate(); + + expect(panelB.hidden).toBe(false); + }); + + it("does not re-apply tab selection when already synced", async () => { + document.body.innerHTML = ` +
+ +
Panel A
+
Panel B
+
+ `; + + await waitForTabsUpdate(); + + const [, tabB] = document.querySelectorAll('[data-pathogen--tabs-target="tab"]'); + let ariaSelectedUpdates = 0; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === "aria-selected" && mutation.target === tabB) { + ariaSelectedUpdates += 1; + } + }); + }); + + observer.observe(tabB, { attributes: true, attributeFilter: ["aria-selected"] }); + + tabB.click(); + await waitForTabsUpdate(); + tabB.click(); + await waitForTabsUpdate(); + + observer.disconnect(); + + expect(ariaSelectedUpdates).toBe(1); + }); + + it("ignores turbo render when tab selection is already synced", async () => { + document.body.innerHTML = ` +
+ +
Panel A
+
Panel B
+
+ `; + + await waitForTabsUpdate(); + + const [, tabB] = document.querySelectorAll('[data-pathogen--tabs-target="tab"]'); + let ariaSelectedUpdates = 0; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === "aria-selected" && mutation.target === tabB) { + ariaSelectedUpdates += 1; + } + }); + }); + + observer.observe(tabB, { attributes: true, attributeFilter: ["aria-selected"] }); + + tabB.click(); + await waitForTabsUpdate(); + + const updatesBeforeTurboRender = ariaSelectedUpdates; + document.dispatchEvent(new Event("turbo:render")); + await waitForTabsUpdate(); + + observer.disconnect(); + + expect(updatesBeforeTurboRender).toBe(1); + expect(ariaSelectedUpdates).toBe(1); + }); + it("renders Pathogen-styled validation error when tab and panel counts mismatch", async () => { document.body.innerHTML = `
From 5b969d6e75612a7d03281ba73eba8dd54367f46a Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Thu, 9 Jul 2026 13:18:00 -0500 Subject: [PATCH 02/10] fix(tabs): validate full tab and panel sync before skipping updates --- .../tabs_controller.js | 19 ++++-- .../controllers/tabs_controller.test.js | 63 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/pathogen_view_components/tabs_controller.js b/app/assets/javascripts/pathogen_view_components/tabs_controller.js index c1976d63..8fe57885 100644 --- a/app/assets/javascripts/pathogen_view_components/tabs_controller.js +++ b/app/assets/javascripts/pathogen_view_components/tabs_controller.js @@ -169,7 +169,7 @@ export default class extends Controller { return; } - if (this.#selectedIndex === tabIndex && this.#isTabSelectionSynced(tabIndex)) { + if (this.#selectedIndex === tabIndex && this.#isSelectionSynced(tabIndex)) { return; } @@ -593,9 +593,20 @@ export default class extends Controller { return false; } - const tab = this.tabTargets[index]; + return this.tabTargets.every((tab, i) => { + if (!tab) return false; + + const isSelected = i === index; + const selectedValue = String(isSelected); + const stateValue = isSelected ? "active" : "inactive"; + const tabIndexValue = isSelected ? 0 : -1; - return tab?.getAttribute("aria-selected") === "true" && tab?.dataset.state === "active" && tab?.tabIndex === 0; + return ( + tab.getAttribute("aria-selected") === selectedValue && + tab.dataset.state === stateValue && + tab.tabIndex === tabIndexValue + ); + }); } /** @@ -653,7 +664,7 @@ export default class extends Controller { const tab = this.tabTargets[index]; - if (this.#selectedIndex === index && document.activeElement === tab && this.#isTabSelectionSynced(index)) { + if (this.#selectedIndex === index && document.activeElement === tab && this.#isSelectionSynced(index)) { return; } diff --git a/test/javascript/controllers/tabs_controller.test.js b/test/javascript/controllers/tabs_controller.test.js index a776b6f4..4dbb93b5 100644 --- a/test/javascript/controllers/tabs_controller.test.js +++ b/test/javascript/controllers/tabs_controller.test.js @@ -197,6 +197,69 @@ describe("tabs_controller", () => { expect(ariaSelectedUpdates).toBe(1); }); + it("re-syncs panels when clicking the already-selected tab after panel state drifts", async () => { + document.body.innerHTML = ` +
+ +
Panel A
+
Panel B
+
+ `; + + await waitForTabsUpdate(); + + const [tabA] = document.querySelectorAll('[data-pathogen--tabs-target="tab"]'); + const [overviewPanel] = document.querySelectorAll('[data-pathogen--tabs-target="panel"]'); + + overviewPanel.hidden = true; + overviewPanel.setAttribute("aria-hidden", "true"); + overviewPanel.dataset.state = "inactive"; + + tabA.click(); + await waitForTabsUpdate(); + + expect(overviewPanel.hidden).toBe(false); + expect(overviewPanel.getAttribute("aria-hidden")).toBe("false"); + expect(overviewPanel.dataset.state).toBe("active"); + }); + + it("re-applies tab selection when inactive tabs have stale roving state", async () => { + document.body.innerHTML = ` +
+ +
Panel A
+
Panel B
+
+ `; + + await waitForTabsUpdate(); + + const [tabA, tabB] = document.querySelectorAll('[data-pathogen--tabs-target="tab"]'); + + tabB.click(); + await waitForTabsUpdate(); + + tabA.setAttribute("aria-selected", "true"); + tabA.dataset.state = "active"; + tabA.tabIndex = 0; + + tabB.click(); + await waitForTabsUpdate(); + + expect(tabA.getAttribute("aria-selected")).toBe("false"); + expect(tabA.dataset.state).toBe("inactive"); + expect(tabA.tabIndex).toBe(-1); + expect(tabB.getAttribute("aria-selected")).toBe("true"); + expect(tabB.dataset.state).toBe("active"); + expect(tabB.tabIndex).toBe(0); + }); + it("ignores turbo render when tab selection is already synced", async () => { document.body.innerHTML = `
Date: Mon, 8 Jun 2026 13:43:54 -0500 Subject: [PATCH 03/10] chore(deps): add msw for lookbook demo mocks MSW intercepts Turbo Frame requests in the Lookbook preview without adding demo-only Rails routes. --- demo/public/mockServiceWorker.js | 349 +++++++++++++++++++++++++++ package.json | 8 +- pnpm-lock.yaml | 397 ++++++++++++++++++++++++++++++- 3 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 demo/public/mockServiceWorker.js diff --git a/demo/public/mockServiceWorker.js b/demo/public/mockServiceWorker.js new file mode 100644 index 00000000..33dde9e7 --- /dev/null +++ b/demo/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.14.6' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/package.json b/package.json index 8695fdbd..314b54a0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "eslint-config-prettier": "^10.0.0", "globals": "^17.7.0", "jsdom": "^29.1.1", + "msw": "^2.14.6", "prettier": "^3.9.5", "tailwindcss": "^4.3.2", "vite": "^7.3.6", @@ -53,5 +54,10 @@ "esbuild": "^0.28.1" } }, - "license": "MIT" + "license": "MIT", + "msw": { + "workerDirectory": [ + "demo/public" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b562c58..dd83db2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1 + msw: + specifier: ^2.14.6 + version: 2.15.0(@types/node@26.1.1) prettier: specifier: ^3.9.5 version: 3.9.5 @@ -73,10 +76,10 @@ importers: version: 4.3.2 vite: specifier: ^7.3.6 - version: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(jsdom@29.1.1)(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) packages: @@ -427,6 +430,41 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -443,6 +481,22 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -777,6 +831,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -823,6 +886,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -873,9 +940,28 @@ packages: resolution: {integrity: sha512-XdLrg7BnbXKntyrbs2dNjDN9CVoTQ+WV0i7jT5/r9ahzAaSDSzC9e2OVZB/QVwbxBb1/1AeObzjlxsYk5HFvww==} engines: {node: '>=8'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -925,6 +1011,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -1017,6 +1106,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1053,6 +1151,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1064,10 +1166,17 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1092,10 +1201,17 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1245,6 +1361,20 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.15.0: + resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1269,6 +1399,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1295,6 +1428,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1365,6 +1501,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1378,6 +1518,9 @@ packages: engines: {node: '>= 0.4'} hasBin: true + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1387,6 +1530,9 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1398,6 +1544,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1405,9 +1555,24 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1419,6 +1584,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwindcss@4.3.2: resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} @@ -1468,10 +1637,20 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1590,6 +1769,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1597,11 +1780,23 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1881,6 +2076,33 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@2.0.7': {} + + '@inquirer/confirm@6.1.1(@types/node@26.1.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@26.1.1) + '@inquirer/type': 4.0.7(@types/node@26.1.1) + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/core@11.2.1(@types/node@26.1.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@26.1.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 26.1.1 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/type@4.0.7(@types/node@26.1.1)': + optionalDependencies: + '@types/node': 26.1.1 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1900,6 +2122,26 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -2149,6 +2391,16 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 26.1.1 + + '@types/statuses@2.0.6': {} + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -2158,13 +2410,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + msw: 2.15.0(@types/node@26.1.1) + vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -2205,6 +2458,10 @@ snapshots: ansi-regex@5.0.1: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@5.2.0: {} aria-query@5.3.0: @@ -2244,8 +2501,24 @@ snapshots: parent-module: 2.0.0 resolve-from: 5.0.0 + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + convert-source-map@2.0.0: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2284,6 +2557,8 @@ snapshots: dom-accessibility-api@0.6.3: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -2410,6 +2685,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2443,6 +2728,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2451,10 +2738,17 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.14.2: {} + hasown@2.0.3: dependencies: function-bind: 1.1.2 + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -2473,10 +2767,14 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -2606,6 +2904,31 @@ snapshots: ms@2.1.3: {} + msw@2.15.0(@types/node@26.1.1): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@26.1.1) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + nanoid@3.3.12: {} nanoid@3.3.15: {} @@ -2625,6 +2948,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outvariant@1.4.3: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -2647,6 +2972,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -2705,6 +3032,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@5.0.0: {} @@ -2716,6 +3045,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + rettime@0.11.11: {} + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 @@ -2751,6 +3082,8 @@ snapshots: dependencies: xmlchars: 2.2.0 + set-cookie-parser@3.1.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2759,12 +3092,28 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2773,6 +3122,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwindcss@4.3.2: {} tapable@2.3.3: {} @@ -2815,15 +3166,23 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + + undici-types@8.3.0: {} + undici@7.28.0: {} + until-async@3.0.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 uuid@14.0.1: {} - vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): + vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -2832,15 +3191,16 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.1.1 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 yaml: 2.9.0 - vitest@4.1.10(jsdom@29.1.1)(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2857,9 +3217,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 26.1.1 jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -2891,10 +3252,30 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + y18n@5.0.8: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} From 2185127bfde21d9ec236c93d6cf526274273fd2f Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Mon, 8 Jun 2026 13:44:46 -0500 Subject: [PATCH 04/10] feat(tabs-preview): add turbo frame lazy load demo Lookbook previews now start MSW before Turbo boots so lazy tab panels can fetch mocked frame responses without demo routes. --- demo/Gemfile | 1 + .../lookbook_mocks/tabs_lazy_load.js | 97 +++++++++++++++++++ demo/app/javascript/lookbook_preview.js | 9 ++ .../views/layouts/lookbook_preview.html.erb | 2 +- demo/config/importmap.rb | 4 + eslint.config.js | 18 ++++ .../previews/pathogen/tabs_preview.rb | 4 + .../tabs_preview/advanced_features.html.erb | 5 +- .../turbo_frame_lazy_load.html.erb | 77 +++++++++++++++ 9 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 demo/app/javascript/lookbook_mocks/tabs_lazy_load.js create mode 100644 demo/app/javascript/lookbook_preview.js create mode 100644 test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb diff --git a/demo/Gemfile b/demo/Gemfile index dee0dfbd..cd0e532e 100644 --- a/demo/Gemfile +++ b/demo/Gemfile @@ -9,6 +9,7 @@ gem 'puma', '>= 5.0' gem 'rails', '~> 8.1.1' gem 'rails_icons' gem 'tailwindcss-rails' +gem 'turbo-rails' gem 'tzinfo-data', platforms: %i[windows jruby] diff --git a/demo/app/javascript/lookbook_mocks/tabs_lazy_load.js b/demo/app/javascript/lookbook_mocks/tabs_lazy_load.js new file mode 100644 index 00000000..e3304eaf --- /dev/null +++ b/demo/app/javascript/lookbook_mocks/tabs_lazy_load.js @@ -0,0 +1,97 @@ +import { delay, http, HttpResponse } from "msw"; +import { setupWorker } from "msw/browser"; + +const LAZY_LOAD_ROUTE = "/lookbook-mocks/tabs/lazy-load/:panel"; + +const frameResponses = { + metrics: { + frameId: "preview-metrics-frame", + title: "Quality Metrics", + body: ` +
+
+
Pass rate
+
98.4%
+
+
+
Samples reviewed
+
1,284
+
+
+
Median turnaround
+
18h
+
+
+ `, + }, + timeline: { + frameId: "preview-timeline-frame", + title: "Processing Timeline", + body: ` +
    +
  1. +
    09:10 - Intake completed
    +

    Metadata passed validation and entered the sequencing queue.

    +
  2. +
  3. +
    11:45 - Quality control passed
    +

    Coverage and contamination checks are within expected thresholds.

    +
  4. +
  5. +
    14:30 - Report generated
    +

    The summary is ready for review in the reporting workspace.

    +
  6. +
+ `, + }, +}; + +let workerStart; + +function turboFrameResponse({ frameId, title, body }) { + return ` + +
+
+

${title}

+ Loaded by MSW +
+ ${body} +
+
+ `; +} + +const handlers = [ + http.get(LAZY_LOAD_ROUTE, async ({ params }) => { + const response = frameResponses[params.panel]; + + if (!response) { + return HttpResponse.html( + '

Unknown lazy-load preview panel.

', + { status: 404 }, + ); + } + + await delay(800); + + return HttpResponse.html(turboFrameResponse(response)); + }), +]; + +const worker = setupWorker(...handlers); + +export function enableTabsLazyLoadMocks() { + if (!("serviceWorker" in navigator)) { + return Promise.resolve(); + } + + workerStart ??= worker.start({ + serviceWorker: { + url: "/mockServiceWorker.js", + }, + onUnhandledRequest: "bypass", + }); + + return workerStart; +} diff --git a/demo/app/javascript/lookbook_preview.js b/demo/app/javascript/lookbook_preview.js new file mode 100644 index 00000000..7bac9de6 --- /dev/null +++ b/demo/app/javascript/lookbook_preview.js @@ -0,0 +1,9 @@ +import { enableTabsLazyLoadMocks } from "lookbook_mocks/tabs_lazy_load"; + +try { + await enableTabsLazyLoadMocks(); +} catch (error) { + console.warn("[pathogen lookbook] Lazy-load mocks are unavailable.", error); +} + +await import("application"); diff --git a/demo/app/views/layouts/lookbook_preview.html.erb b/demo/app/views/layouts/lookbook_preview.html.erb index 7b4d316f..c127ec39 100644 --- a/demo/app/views/layouts/lookbook_preview.html.erb +++ b/demo/app/views/layouts/lookbook_preview.html.erb @@ -8,7 +8,7 @@ <%= stylesheet_link_tag "pathogen_view_components" %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "lookbook_preview" %> - <%= javascript_importmap_tags %> + <%= javascript_importmap_tags "lookbook_preview" %> diff --git a/demo/config/importmap.rb b/demo/config/importmap.rb index 31fca38c..a0d400dd 100644 --- a/demo/config/importmap.rb +++ b/demo/config/importmap.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true pin 'application' +pin 'lookbook_preview' +pin 'lookbook_mocks/tabs_lazy_load' pin '@hotwired/turbo-rails', to: 'https://cdn.jsdelivr.net/npm/@hotwired/turbo-rails@8.0.23/+esm' pin '@hotwired/stimulus', to: 'https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js' +pin 'msw', to: 'https://cdn.jsdelivr.net/npm/msw@2.14.6/+esm' +pin 'msw/browser', to: 'https://cdn.jsdelivr.net/npm/msw@2.14.6/browser/+esm' diff --git a/eslint.config.js b/eslint.config.js index 45051407..3fa7a098 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,6 +27,24 @@ export default defineConfig([ globals: { ...globals.node }, }, }, + { + name: "demo-javascript", + files: ["demo/app/javascript/**/*.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + "no-console": ["warn", { allow: ["error", "warn"] }], + "no-var": "error", + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "prefer-const": "error", + }, + }, { name: "pathogen-javascript", files: ["app/assets/javascripts/**/*.js", "test/javascript/**/*.js"], diff --git a/test/components/previews/pathogen/tabs_preview.rb b/test/components/previews/pathogen/tabs_preview.rb index 133478bf..639817d7 100644 --- a/test/components/previews/pathogen/tabs_preview.rb +++ b/test/components/previews/pathogen/tabs_preview.rb @@ -26,6 +26,10 @@ def url_sync; end # Default selection, edge cases, and lazy loading patterns def advanced_features; end + # @label Turbo Frame Lazy Load + # Demonstrates lazy-loaded tab panels using Turbo Frames and demo-only request mocks + def turbo_frame_lazy_load; end + # @label Integration Examples # Real-world usage patterns with forms, navigation, and content organization def integration_examples; end diff --git a/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb b/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb index 400097a6..ed3f3f07 100644 --- a/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb +++ b/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb @@ -86,8 +86,9 @@

Pair inactive panels with loading: :lazy turbo frames, or use - with_lazy_panel for conditional eager/lazy rendering. The skeleton below matches - the loading shell shipped with lazy panels. + with_lazy_panel for conditional eager/lazy rendering. Open the + Turbo Frame Lazy Load preview for a working demo. The skeleton below matches the loading shell + shipped with lazy panels.

diff --git a/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb b/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb new file mode 100644 index 00000000..bd99e147 --- /dev/null +++ b/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb @@ -0,0 +1,77 @@ +
+
+

Turbo Frame Lazy Load

+ +

+ The first panel is rendered with the page. The other panels use + with_lazy_panel and load mocked + Turbo Frame responses when their tabs become visible. +

+
+ +
+ <%= render(Pathogen::Tabs.new(id: 'lazy-load-preview-tabs', label: 'Lazy-loaded preview data')) do |tabs| %> + <% tabs.with_tab(id: 'preview-tab-summary', label: 'Summary', selected: true) %> + <% tabs.with_tab(id: 'preview-tab-metrics', label: 'Metrics') %> + <% tabs.with_tab(id: 'preview-tab-timeline', label: 'Timeline') %> + + <% tabs.with_panel(id: 'preview-panel-summary', tab_id: 'preview-tab-summary') do %> +
+

Run Summary

+ +

+ This content is available immediately because the selected tab renders a normal panel. Switching to the + other tabs reveals a hidden panel, which lets Turbo fetch that panel's lazy frame. +

+ +
+
+
Batch
+
PV-2026-06-08
+
+ +
+
Status
+
Ready for review
+
+ +
+
Updated
+
10:45 AM
+
+
+
+ <% end %> + + <% tabs.with_lazy_panel( + id: 'preview-panel-metrics', + tab_id: 'preview-tab-metrics', + frame_id: 'preview-metrics-frame', + src_path: '/lookbook-mocks/tabs/lazy-load/metrics', + selected: false, + ) do %> + Metrics content loads from the mocked Turbo Frame endpoint. + <% end %> + + <% tabs.with_lazy_panel( + id: 'preview-panel-timeline', + tab_id: 'preview-tab-timeline', + frame_id: 'preview-timeline-frame', + src_path: '/lookbook-mocks/tabs/lazy-load/timeline', + selected: false, + ) do %> + Timeline content loads from the mocked Turbo Frame endpoint. + <% end %> + <% end %> +
+ +
+ The Lookbook demo starts MSW before Turbo boots, so the lazy frame requests are intercepted in the browser without + adding demo-only Rails routes. +
+
From c38f7fd4a72fc4e1d157fe8d38376c719c364490 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Tue, 16 Jun 2026 15:55:39 -0500 Subject: [PATCH 05/10] feat(lazy-panel): update turbo frame rendering to support fallback for missing helpers --- .../pathogen/tabs/lazy_panel.html.erb | 5 ++--- app/components/pathogen/tabs/lazy_panel.rb | 11 +++++++++++ .../pathogen/tabs/lazy_panel_test.rb | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/components/pathogen/tabs/lazy_panel.html.erb b/app/components/pathogen/tabs/lazy_panel.html.erb index 57e93526..9138c940 100644 --- a/app/components/pathogen/tabs/lazy_panel.html.erb +++ b/app/components/pathogen/tabs/lazy_panel.html.erb @@ -1,8 +1,7 @@ <% if render_eager? %> - <%= helpers.turbo_frame_tag(@frame_id) { content } %> + <%= render_turbo_frame { content } %> <% else %> - <%= helpers.turbo_frame_tag( - @frame_id, + <%= render_turbo_frame( src: @src_path, loading: :lazy, refresh: @refresh, diff --git a/app/components/pathogen/tabs/lazy_panel.rb b/app/components/pathogen/tabs/lazy_panel.rb index 7428baa0..218707c3 100644 --- a/app/components/pathogen/tabs/lazy_panel.rb +++ b/app/components/pathogen/tabs/lazy_panel.rb @@ -85,6 +85,17 @@ def validate_arguments!(frame_id:, src_path:, selected:) def render_eager? @selected end + + # Renders a turbo-frame in environments with or without turbo-rails helpers. + def render_turbo_frame(**options, &) + frame_options = @system_arguments.except(:id).merge(options) + + if helpers.respond_to?(:turbo_frame_tag) + helpers.turbo_frame_tag(@frame_id, **frame_options, &) + else + content_tag('turbo-frame', id: @frame_id, **frame_options, &) + end + end end end end diff --git a/test/components/pathogen/tabs/lazy_panel_test.rb b/test/components/pathogen/tabs/lazy_panel_test.rb index 9870a80e..4aa73b42 100644 --- a/test/components/pathogen/tabs/lazy_panel_test.rb +++ b/test/components/pathogen/tabs/lazy_panel_test.rb @@ -161,6 +161,24 @@ class LazyPanelTest < ViewComponent::TestCase frame_content = page.find('turbo-frame#content-frame').text.strip assert_empty frame_content end + + test 'falls back to native turbo-frame when helper is unavailable' do + component = Pathogen::Tabs::LazyPanel.new( + frame_id: 'content-frame', + src_path: '/path/to/content', + selected: false + ) + + component.define_singleton_method(:helpers) { Object.new } + + render_inline(component) do + 'Fallback content' + end + + assert_selector 'turbo-frame#content-frame[src="/path/to/content"]' + assert_selector 'turbo-frame[loading="lazy"]' + assert_selector 'turbo-frame[refresh="morph"]' + end end end end From 9f2b61a8a3e108797f7766383f2e6a6178cce238 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Fri, 3 Jul 2026 12:26:06 -0500 Subject: [PATCH 06/10] build(css): anchor meta and section type utilities Lookbook previews use type token classes outside the Tailwind scan path. Inline @source keeps those utilities in the artifact. --- app/assets/stylesheets/pathogen.tailwind.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pathogen.tailwind.css b/app/assets/stylesheets/pathogen.tailwind.css index e732059f..5b7c37d5 100644 --- a/app/assets/stylesheets/pathogen.tailwind.css +++ b/app/assets/stylesheets/pathogen.tailwind.css @@ -17,7 +17,7 @@ @source "../../assets/javascripts/**/*.js"; @source "../../../lib/**/*.rb"; /* Anchor utilities so full @theme primary scale and `text-primary` emit for host overrides. */ -@source inline("text-primary bg-primary-950 text-red-300 text-red-700"); +@source inline("text-primary bg-primary-950 text-red-300 text-red-700 text-[length:var(--type-meta)] text-[length:var(--type-section)]"); /* * Theme contract (host apps may override after @import): From 527d2d266e838a52d155dd9b2c815a82ffb0ce3d Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Fri, 3 Jul 2026 12:27:21 -0500 Subject: [PATCH 07/10] refactor(tabs-preview): consolidate and use typography Reorder examples, drop redundant advanced_features preview, align surfaces with the design contract, and render preview chrome through Pathogen typography helpers. --- .../previews/pathogen/tabs_preview.rb | 35 ++- .../tabs_preview/advanced_features.html.erb | 126 ---------- .../tabs_preview/basic_usage.html.erb | 219 +++++++++++------- .../integration_examples.html.erb | 97 ++------ .../keyboard_and_accessibility.html.erb | 84 +++---- .../tabs_preview/orientations.html.erb | 109 ++++----- .../turbo_frame_lazy_load.html.erb | 117 +++++----- .../pathogen/tabs_preview/url_sync.html.erb | 71 +++--- 8 files changed, 348 insertions(+), 510 deletions(-) delete mode 100644 test/components/previews/pathogen/tabs_preview/advanced_features.html.erb diff --git a/test/components/previews/pathogen/tabs_preview.rb b/test/components/previews/pathogen/tabs_preview.rb index 639817d7..5ed474ff 100644 --- a/test/components/previews/pathogen/tabs_preview.rb +++ b/test/components/previews/pathogen/tabs_preview.rb @@ -2,36 +2,33 @@ module Pathogen # ViewComponent preview for demonstrating Pathogen::Tabs usage - # Showcases accessibility features, keyboard navigation, and various configurations class TabsPreview < ViewComponent::Preview + include Pathogen::ViewHelper + # @!group Pathogen Tabs Component - # @label Basic Usage & Getting Started - # Simple examples showing basic tab functionality with default settings + # @label Overview + # Horizontal tabs with run detail content, default selection, and edge cases def basic_usage; end - # @label Keyboard Navigation & Accessibility - # Demonstrates keyboard controls, ARIA patterns, and accessibility features - def keyboard_and_accessibility; end - - # @label Orientations & Layouts - # Shows horizontal and vertical tab orientations with different layouts + # @label Orientations + # Vertical layout, tab overflow, and orientation comparison def orientations; end - # @label URL Synchronization - # Bookmarkable tabs with URL hash syncing and browser navigation - def url_sync; end + # @label Accessibility + # Keyboard navigation and ARIA wiring reference + def keyboard_and_accessibility; end - # @label Advanced Features - # Default selection, edge cases, and lazy loading patterns - def advanced_features; end + # @label URL Sync + # Bookmarkable tabs with hash synchronization + def url_sync; end - # @label Turbo Frame Lazy Load - # Demonstrates lazy-loaded tab panels using Turbo Frames and demo-only request mocks + # @label Lazy Loading + # Turbo Frame lazy panels with demo request mocks def turbo_frame_lazy_load; end - # @label Integration Examples - # Real-world usage patterns with forms, navigation, and content organization + # @label Integration + # Settings layout and form footer patterns def integration_examples; end # @!endgroup diff --git a/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb b/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb deleted file mode 100644 index ed3f3f07..00000000 --- a/test/components/previews/pathogen/tabs_preview/advanced_features.html.erb +++ /dev/null @@ -1,126 +0,0 @@ -<%# herb:formatter ignore %> - -
-
-

Custom default tab

- -

- Use default_index to open a specific tab on first render. This review workflow - starts on the QC step. -

- -
- <%= render(Pathogen::Tabs.new( - id: 'tabs-custom-default', - label: 'Submission review steps', - default_index: 2 - )) do |tabs| %> - <% tabs.with_tab(id: 'tab-intake', label: 'Intake') %> - <% tabs.with_tab(id: 'tab-prep', label: 'Preparation') %> - <% tabs.with_tab(id: 'tab-qc', label: 'QC review') %> - <% tabs.with_tab(id: 'tab-release', label: 'Release') %> - - <% tabs.with_panel(id: 'panel-intake', tab_id: 'tab-intake') do %> -

48 specimens received from SITE-042.

- <% end %> - - <% tabs.with_panel(id: 'panel-prep', tab_id: 'tab-prep') do %> -

Library prep complete for 46 specimens.

- <% end %> - - <% tabs.with_panel(id: 'panel-qc', tab_id: 'tab-qc') do %> -

- Two specimens flagged for low coverage. Review before release. -

- <% end %> - - <% tabs.with_panel(id: 'panel-release', tab_id: 'tab-release') do %> -

Release blocked until QC review is complete.

- <% end %> - <% end %> -
-
- -
-

Edge cases

- -
-
-

Single tab

- - <%= render(Pathogen::Tabs.new(id: 'single-tab', label: 'Run status')) do |tabs| %> - <% tabs.with_tab(id: 'tab-only', label: 'Status') %> - - <% tabs.with_panel(id: 'panel-only', tab_id: 'tab-only') do %> -

- Valid, but consider a plain heading when only one view exists. -

- <% end %> - <% end %> -
- -
-

Many tabs

- - <%= render(Pathogen::Tabs.new(id: 'many-tabs', label: 'Batch lanes')) do |tabs| %> - <% (1..8).each do |i| %> - <% tabs.with_tab(id: "tab-lane-#{i}", label: "Lane #{i}", selected: i == 1) %> - <% end %> - - <% (1..8).each do |i| %> - <% tabs.with_panel(id: "panel-lane-#{i}", tab_id: "tab-lane-#{i}") do %> -

Metrics for lane <%= i %>.

- <% end %> - <% end %> - <% end %> - -

- Horizontal tabs wrap. For long lists, prefer vertical orientation. -

-
-
-
- -
-

Turbo Frame lazy loading

- -

- Pair inactive panels with loading: :lazy turbo frames, or use - with_lazy_panel for conditional eager/lazy rendering. Open the - Turbo Frame Lazy Load preview for a working demo. The skeleton below matches the loading shell - shipped with lazy panels. -

- -
-
-
-
-
-
-
-
-
-
-
-
- -
<%= render Pathogen::Tabs.new(id: "run-tabs", label: "Run sections") do |tabs| %>
-  <% tabs.with_tab(id: "tab-summary", label: "Summary", selected: true) %>
-  <% tabs.with_tab(id: "tab-analytics", label: "Analytics") %>
-
-  <% tabs.with_panel(id: "panel-summary", tab_id: "tab-summary") do %>
-    <%= render "runs/summary" %>
-  <% end %>
-
-  <% tabs.with_lazy_panel(
-    id: "panel-analytics",
-    tab_id: "tab-analytics",
-    frame_id: "run-analytics",
-    src_path: analytics_run_path(@run),
-    selected: false
-  ) do %>
-    <%= render "runs/analytics" %>
-  <% end %>
-<% end %>
-
-
diff --git a/test/components/previews/pathogen/tabs_preview/basic_usage.html.erb b/test/components/previews/pathogen/tabs_preview/basic_usage.html.erb index 5e58afd8..50786f9e 100644 --- a/test/components/previews/pathogen/tabs_preview/basic_usage.html.erb +++ b/test/components/previews/pathogen/tabs_preview/basic_usage.html.erb @@ -1,90 +1,149 @@ <%# herb:formatter ignore %> -
-
-

Sequencing run review

-

- Section tabs for switching run detail views. Use arrow keys to move between tabs; selection activates immediately. -

-
- -
- <%= render(Pathogen::Tabs.new(id: 'run-review-tabs', label: 'Sequencing run sections')) do |tabs| %> - <% tabs.with_tab(id: 'tab-summary', label: 'Summary', selected: true) %> - <% tabs.with_tab(id: 'tab-qc', label: 'QC') %> - <% tabs.with_tab(id: 'tab-reads', label: 'Reads') %> - <% tabs.with_tab(id: 'tab-metadata', label: 'Metadata') %> - - <% tabs.with_panel(id: 'panel-summary', tab_id: 'tab-summary') do %> -
-
-
Run ID
-
RUN-2026-0142
-
-
-
Instrument
-
NovaSeq X Plus
-
-
-
Samples
-
48 submitted · 46 passed initial QC
-
-
-
Status
-
Ready for review
-
-
- <% end %> +
+
+ <%= pathogen_heading(level: 2, class: "mb-2") { "Sequencing run review" } %> - <% tabs.with_panel(id: 'panel-qc', tab_id: 'tab-qc') do %> -
-
- Mean Q30 - 92.4% -
-
- Clusters passing filter - 89.1% -
-
- PhiX alignment rate - 0.42% — review recommended + <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + Horizontal section tabs for switching run detail views. Each tab requires a matching panel with a unique id. + Arrow keys move between tabs; selection activates immediately. + <% end %> + +
+ <%= render(Pathogen::Tabs.new(id: 'run-review-tabs', label: 'Sequencing run sections')) do |tabs| %> + <% tabs.with_tab(id: 'tab-summary', label: 'Summary', selected: true) %> + <% tabs.with_tab(id: 'tab-qc', label: 'QC') %> + <% tabs.with_tab(id: 'tab-reads', label: 'Reads') %> + <% tabs.with_tab(id: 'tab-metadata', label: 'Metadata') %> + + <% tabs.with_panel(id: 'panel-summary', tab_id: 'tab-summary') do %> +
+
+
Run ID
+
RUN-2026-0142
+
+
+
Instrument
+
NovaSeq X Plus
+
+
+
Samples
+
48 submitted · 46 passed initial QC
+
+
+
Status
+
Ready for review
+
+
+ <% end %> + + <% tabs.with_panel(id: 'panel-qc', tab_id: 'tab-qc') do %> +
+
+ Mean Q30 + 92.4% +
+
+ Clusters passing filter + 89.1% +
+
+ PhiX alignment rate + 0.42% — review recommended +
-
- <% end %> + <% end %> + + <% tabs.with_panel(id: 'panel-reads', tab_id: 'tab-reads') do %> + <%= pathogen_text(variant: :muted) do %> + 1.84B paired-end reads generated. Open the reads table from the run workspace to inspect per-lane metrics. + <% end %> + <% end %> - <% tabs.with_panel(id: 'panel-reads', tab_id: 'tab-reads') do %> -

- 1.84B paired-end reads generated. Open the reads table from the run workspace to inspect per-lane metrics. -

+ <% tabs.with_panel(id: 'panel-metadata', tab_id: 'tab-metadata') do %> +
+
+
Protocol
+
WGS bacterial v3.2
+
+
+
Operator
+
lab-seq-review
+
+
+
Submitted
+
2026-06-10 14:32 UTC
+
+
+ <% end %> <% end %> +
+
- <% tabs.with_panel(id: 'panel-metadata', tab_id: 'tab-metadata') do %> -
-
-
Protocol
-
WGS bacterial v3.2
-
-
-
Operator
-
lab-seq-review
-
-
-
Submitted
-
2026-06-10 14:32 UTC
-
-
+
+ <%= pathogen_heading(level: 2, class: "mb-2") { "Custom default tab" } %> + + <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + Pass <%= pathogen_code { "default_index" } %> to open a specific tab on first render. This review workflow + starts on the QC step. + <% end %> + +
+ <%= render(Pathogen::Tabs.new( + id: 'tabs-custom-default', + label: 'Submission review steps', + default_index: 2 + )) do |tabs| %> + <% tabs.with_tab(id: 'tab-intake', label: 'Intake') %> + <% tabs.with_tab(id: 'tab-prep', label: 'Preparation') %> + <% tabs.with_tab(id: 'tab-qc', label: 'QC review') %> + <% tabs.with_tab(id: 'tab-release', label: 'Release') %> + + <% tabs.with_panel(id: 'panel-intake', tab_id: 'tab-intake') do %> + <%= pathogen_text(variant: :muted) { "48 specimens received from SITE-042." } %> + <% end %> + + <% tabs.with_panel(id: 'panel-prep', tab_id: 'tab-prep') do %> + <%= pathogen_text(variant: :muted) { "Library prep complete for 46 specimens." } %> + <% end %> + + <% tabs.with_panel(id: 'panel-qc', tab_id: 'tab-qc') do %> + <%= pathogen_text do %> + Two specimens flagged for low coverage. Review before release. + <% end %> + <% end %> + + <% tabs.with_panel(id: 'panel-release', tab_id: 'tab-release') do %> + <%= pathogen_text(variant: :muted) { "Release blocked until QC review is complete." } %> + <% end %> <% end %> +
+
+ +
+ <%= pathogen_heading(level: 2, class: "mb-2") { "Single tab" } %> + + <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + A single tab is valid, but consider a plain heading when only one view exists. <% end %> -
- -
-

Usage

-

- Each tab requires a matching panel. Tab and panel IDs must be unique. Keyboard: - - - to navigate; focus moves to the panel with Tab. -

-
+ +
+ <%= render(Pathogen::Tabs.new(id: 'single-tab', label: 'Run status')) do |tabs| %> + <% tabs.with_tab(id: 'tab-only', label: 'Status') %> + + <% tabs.with_panel(id: 'panel-only', tab_id: 'tab-only') do %> +
+
+
Run ID
+
RUN-2026-0142
+
+
+
Status
+
Ready for review
+
+
+ <% end %> + <% end %> +
+
diff --git a/test/components/previews/pathogen/tabs_preview/integration_examples.html.erb b/test/components/previews/pathogen/tabs_preview/integration_examples.html.erb index 68dc6ad8..705b956d 100644 --- a/test/components/previews/pathogen/tabs_preview/integration_examples.html.erb +++ b/test/components/previews/pathogen/tabs_preview/integration_examples.html.erb @@ -2,11 +2,11 @@
-

Operator account settings

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Operator account settings" } %> -

- Vertical tabs in a settings layout. URL sync keeps the active section bookmarkable. -

+ <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + Vertical tabs in a settings layout with URL sync so the active section stays bookmarkable. + <% end %>
<%= render(Pathogen::Tabs.new( @@ -20,7 +20,7 @@ <% tabs.with_tab(id: 'tab-notifications', label: 'Notifications') %> <% tabs.with_panel(id: 'panel-profile', tab_id: 'tab-profile') do %> -
+
Display name
lab-seq-review
@@ -37,15 +37,15 @@ <% end %> <% tabs.with_panel(id: 'panel-access', tab_id: 'tab-access') do %> -
    -
  • Run review — granted
  • -
  • Protocol edit — granted
  • -
  • User administration — denied
  • -
+ <%= pathogen_list(variant: :muted) do |list| %> + <% list.with_item { "Run review — granted" } %> + <% list.with_item { "Protocol edit — granted" } %> + <% list.with_item { "User administration — denied" } %> + <% end %> <% end %> <% tabs.with_panel(id: 'panel-notifications', tab_id: 'tab-notifications') do %> -
+
QC failure alerts
Email
@@ -61,68 +61,11 @@
-

Specimen record workspace

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Form footer with tabs above" } %> -

- Horizontal tabs organizing dense record detail. Panel content uses ordinary product typography and dividers. -

- -
- <%= render(Pathogen::Tabs.new(id: 'specimen-workspace-tabs', label: 'Specimen record sections')) do |tabs| %> - <% tabs.with_tab(id: 'tab-metadata', label: 'Metadata', selected: true) %> - <% tabs.with_tab(id: 'tab-sequences', label: 'Sequences') %> - <% tabs.with_tab(id: 'tab-related', label: 'Related') %> - - <% tabs.with_panel(id: 'panel-metadata', tab_id: 'tab-metadata') do %> -
-
-
Specimen ID
-
SPEC-2026-00891
-
-
-
Source
-
Clinical isolate
-
-
-
Collected
-
2026-06-09
-
-
-
Submitted
-
2026-06-10
-
-
- <% end %> - - <% tabs.with_panel(id: 'panel-sequences', tab_id: 'tab-sequences') do %> -
-
- SPEC-2026-00891-R1 - Passed QC -
-
- SPEC-2026-00891-R2 - Coverage review -
-
- <% end %> - - <% tabs.with_panel(id: 'panel-related', tab_id: 'tab-related') do %> -

- 2 related specimens within the same epidemiological cluster. Open the investigation workspace for lineage - context. -

- <% end %> - <% end %> -
-
- -
-

Form footer with tabs above

- -

+ <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> Tabs organize read-only review sections; the primary action stays in the form footer below. -

+ <% end %>
<%= render(Pathogen::Tabs.new(id: 'submission-review-tabs', label: 'Submission review')) do |tabs| %> @@ -130,15 +73,15 @@ <% tabs.with_tab(id: 'tab-attachments', label: 'Attachments') %> <% tabs.with_panel(id: 'panel-checklist', tab_id: 'tab-checklist') do %> -
    -
  • Metadata complete
  • -
  • Consent on file
  • -
  • QC thresholds met
  • -
+ <%= pathogen_list(variant: :muted) do |list| %> + <% list.with_item { "Metadata complete" } %> + <% list.with_item { "Consent on file" } %> + <% list.with_item { "QC thresholds met" } %> + <% end %> <% end %> <% tabs.with_panel(id: 'panel-attachments', tab_id: 'tab-attachments') do %> -

chain_of_custody.pdf · lab_requisition.pdf

+ <%= pathogen_text(variant: :muted) { "chain_of_custody.pdf · lab_requisition.pdf" } %> <% end %> <% end %> diff --git a/test/components/previews/pathogen/tabs_preview/keyboard_and_accessibility.html.erb b/test/components/previews/pathogen/tabs_preview/keyboard_and_accessibility.html.erb index 92856c40..35ba8ccd 100644 --- a/test/components/previews/pathogen/tabs_preview/keyboard_and_accessibility.html.erb +++ b/test/components/previews/pathogen/tabs_preview/keyboard_and_accessibility.html.erb @@ -2,16 +2,16 @@
-

Keyboard navigation

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Keyboard navigation" } %> -

+ <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> Tabs follow the W3C automatic activation pattern. Focus a tab, then use arrow keys to move and activate. - Home and - End + Home and + End jump to the first and last tab. Press - Tab + Tab to leave the tab list and enter the panel. -

+ <% end %>
<%= render(Pathogen::Tabs.new(id: 'keyboard-demo-tabs', label: 'Specimen review sections')) do |tabs| %> @@ -21,7 +21,7 @@ <% tabs.with_tab(id: 'kbd-tab-audit', label: 'Audit') %> <% tabs.with_panel(id: 'kbd-panel-collection', tab_id: 'kbd-tab-collection') do %> -
+
Specimen ID
SPEC-2026-00891
@@ -34,19 +34,21 @@ <% end %> <% tabs.with_panel(id: 'kbd-panel-lab', tab_id: 'kbd-tab-lab') do %> -

+ <%= pathogen_text(variant: :muted) do %> Culture negative. WGS pending on paired isolate SPEC-2026-00891-A. -

+ <% end %> <% end %> <% tabs.with_panel(id: 'kbd-panel-qc', tab_id: 'kbd-tab-qc') do %> -

Coverage below threshold on contig 3 — review required.

+ <%= pathogen_supporting(class: "text-[var(--pvc-color-warning)]") do %> + Coverage below threshold on contig 3 — review required. + <% end %> <% end %> <% tabs.with_panel(id: 'kbd-panel-audit', tab_id: 'kbd-tab-audit') do %> -

+ <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> Last updated by lab-seq-review at 2026-06-10 14:32 UTC. -

+ <% end %> <%= render Pathogen::Button.new(tone: :primary, emphasis: :solid, text: 'Open audit log') %> <% end %> @@ -55,71 +57,71 @@
-

ARIA wiring

+ <%= pathogen_heading(level: 2, class: "mb-2") { "ARIA wiring" } %> -

+ <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> The component sets roles, labels, and roving tabindex so screen readers announce tab lists, selection, and panels consistently. -

+ <% end %>
-

Tab list

+ <%= pathogen_heading(level: 3, class: "mb-3") { "Tab list" } %> -
+
-
role
-
tablist on nav
+
<%= pathogen_code { "role" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %><%= pathogen_code { "tablist" } %> on <%= pathogen_code { "nav" } %><% end %>
-
aria-label
-
Names the tab group
+
<%= pathogen_code { "aria-label" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) { "Names the tab group" } %>
-
aria-orientation
-
horizontal or vertical
+
<%= pathogen_code { "aria-orientation" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %><%= pathogen_code { "horizontal" } %> or <%= pathogen_code { "vertical" } %><% end %>
-

Tab buttons

+ <%= pathogen_heading(level: 3, class: "mb-3") { "Tab buttons" } %> -
+
-
role
-
tab on native button
+
<%= pathogen_code { "role" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %><%= pathogen_code { "tab" } %> on native <%= pathogen_code { "button" } %><% end %>
-
aria-selected
-
true on the active tab only
+
<%= pathogen_code { "aria-selected" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %><%= pathogen_code { "true" } %> on the active tab only<% end %>
-
tabindex
-
Roving: 0 active, -1 inactive
+
<%= pathogen_code { "tabindex" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %>Roving: <%= pathogen_code { "0" } %> active, <%= pathogen_code { "-1" } %> inactive<% end %>
-

Panels

+ <%= pathogen_heading(level: 3, class: "mb-3") { "Panels" } %> -
+
-
role
-
tabpanel
+
<%= pathogen_code { "role" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %><%= pathogen_code { "tabpanel" } %><% end %>
-
aria-labelledby
-
References the controlling tab id
+
<%= pathogen_code { "aria-labelledby" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) { "References the controlling tab id" } %>
-
hidden
-
Native attribute toggled for inactive panels
+
<%= pathogen_code { "hidden" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) { "Native attribute toggled for inactive panels" } %>
-
data-state
-
active / inactive for styling hooks
+
<%= pathogen_code { "data-state" } %>
+
<%= pathogen_supporting(variant: :muted, tag: :span) do %><%= pathogen_code { "active" } %> / <%= pathogen_code { "inactive" } %> for styling hooks<% end %>
diff --git a/test/components/previews/pathogen/tabs_preview/orientations.html.erb b/test/components/previews/pathogen/tabs_preview/orientations.html.erb index 0c5fa52a..e7ffd852 100644 --- a/test/components/previews/pathogen/tabs_preview/orientations.html.erb +++ b/test/components/previews/pathogen/tabs_preview/orientations.html.erb @@ -2,65 +2,14 @@
-

Horizontal orientation

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Vertical orientation" } %> -

- Default layout for section navigation across a run or specimen record. Use - - - to move between tabs; selection activates immediately. -

- -
- <%= render(Pathogen::Tabs.new(id: 'horizontal-tabs', label: 'Sequencing run sections', orientation: :horizontal)) do |tabs| %> - <% tabs.with_tab(id: 'h-tab-summary', label: 'Summary', selected: true) %> - <% tabs.with_tab(id: 'h-tab-qc', label: 'QC') %> - <% tabs.with_tab(id: 'h-tab-reads', label: 'Reads') %> - - <% tabs.with_panel(id: 'h-panel-summary', tab_id: 'h-tab-summary') do %> -
-
-
Run ID
-
RUN-2026-0142
-
-
-
Instrument
-
NovaSeq X Plus
-
-
- <% end %> - - <% tabs.with_panel(id: 'h-panel-qc', tab_id: 'h-tab-qc') do %> -
-
- Mean Q30 - 92.4% -
-
- Clusters passing filter - 89.1% -
-
- <% end %> - - <% tabs.with_panel(id: 'h-panel-reads', tab_id: 'h-tab-reads') do %> -

- 1.84B paired-end reads generated. Open the reads table from the run workspace for per-lane metrics. -

- <% end %> - <% end %> -
-
- -
-

Vertical orientation

- -

- Side navigation for settings or long tab lists. Use - - + <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + Side navigation for settings or long tab lists. Horizontal is the default — see Overview. Use + + to navigate. -

+ <% end %>
<%= render(Pathogen::Tabs.new(id: 'vertical-tabs', label: 'Protocol configuration', orientation: :vertical)) do |tabs| %> @@ -70,7 +19,7 @@ <% tabs.with_tab(id: 'v-tab-notifications', label: 'Notifications') %> <% tabs.with_panel(id: 'v-panel-general', tab_id: 'v-tab-general') do %> -
+
Protocol
WGS bacterial v3.2
@@ -83,33 +32,55 @@ <% end %> <% tabs.with_panel(id: 'v-panel-samples', tab_id: 'v-tab-samples') do %> -

+ <%= pathogen_text(variant: :muted) do %> Minimum input DNA 1 ng. Paired-end 150 bp reads. Barcode mismatch tolerance: 1. -

+ <% end %> <% end %> <% tabs.with_panel(id: 'v-panel-qc-thresholds', tab_id: 'v-tab-qc-thresholds') do %> -
    -
  • Mean Q30 ≥ 90%
  • -
  • PhiX alignment rate ≤ 1.0%
  • -
  • Undetermined index reads ≤ 0.5%
  • -
+ <%= pathogen_list(variant: :muted) do |list| %> + <% list.with_item { "Mean Q30 ≥ 90%" } %> + <% list.with_item { "PhiX alignment rate ≤ 1.0%" } %> + <% list.with_item { "Undetermined index reads ≤ 0.5%" } %> + <% end %> <% end %> <% tabs.with_panel(id: 'v-panel-notifications', tab_id: 'v-tab-notifications') do %> -

+ <%= pathogen_text(variant: :muted) do %> Email lab-seq-review when a run fails QC or remains in review for more than 24 hours. -

+ <% end %> + <% end %> + <% end %> +
+
+ +
+ <%= pathogen_heading(level: 2, class: "mb-2") { "Many tabs" } %> + + <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + Horizontal tabs wrap when the list is long. For eight or more sections, prefer vertical orientation. + <% end %> + +
+ <%= render(Pathogen::Tabs.new(id: 'many-tabs', label: 'Batch lanes')) do |tabs| %> + <% (1..8).each do |i| %> + <% tabs.with_tab(id: "tab-lane-#{i}", label: "Lane #{i}", selected: i == 1) %> + <% end %> + + <% (1..8).each do |i| %> + <% tabs.with_panel(id: "panel-lane-#{i}", tab_id: "tab-lane-#{i}") do %> + <%= pathogen_text(variant: :muted) { "Metrics for lane #{i}." } %> + <% end %> <% end %> <% end %>
-

Orientation comparison

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Orientation comparison" } %>
- +
diff --git a/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb b/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb index bd99e147..fcc575b3 100644 --- a/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb +++ b/test/components/previews/pathogen/tabs_preview/turbo_frame_lazy_load.html.erb @@ -1,77 +1,74 @@ -
-
-

Turbo Frame Lazy Load

+<%# herb:formatter ignore %> -

- The first panel is rendered with the page. The other panels use - with_lazy_panel and load mocked - Turbo Frame responses when their tabs become visible. -

-
+
+
+ <%= pathogen_heading(level: 2, class: "mb-2") { "Lazy-loaded panels" } %> -
- <%= render(Pathogen::Tabs.new(id: 'lazy-load-preview-tabs', label: 'Lazy-loaded preview data')) do |tabs| %> - <% tabs.with_tab(id: 'preview-tab-summary', label: 'Summary', selected: true) %> - <% tabs.with_tab(id: 'preview-tab-metrics', label: 'Metrics') %> - <% tabs.with_tab(id: 'preview-tab-timeline', label: 'Timeline') %> - - <% tabs.with_panel(id: 'preview-panel-summary', tab_id: 'preview-tab-summary') do %> -
-

Run Summary

+ <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + The first panel renders with the page. Inactive panels use + <%= pathogen_code { "with_lazy_panel" } %> to defer content until their tab is selected. Switching to + Metrics or Timeline reveals the hidden panel, shows a loading skeleton while the Turbo Frame request is in + flight, then replaces it with the fetched content. + <% end %> -

- This content is available immediately because the selected tab renders a normal panel. Switching to the - other tabs reveals a hidden panel, which lets Turbo fetch that panel's lazy frame. -

+
+ <%= render(Pathogen::Tabs.new(id: 'lazy-load-preview-tabs', label: 'Lazy-loaded run sections')) do |tabs| %> + <% tabs.with_tab(id: 'preview-tab-summary', label: 'Summary', selected: true) %> + <% tabs.with_tab(id: 'preview-tab-metrics', label: 'Metrics') %> + <% tabs.with_tab(id: 'preview-tab-timeline', label: 'Timeline') %> -
+ <% tabs.with_panel(id: 'preview-panel-summary', tab_id: 'preview-tab-summary') do %> +
-
Batch
-
PV-2026-06-08
+
Batch
+
PV-2026-06-08
-
-
Status
-
Ready for review
+
Status
+
Ready for review
-
-
Updated
-
10:45 AM
+
Updated
+
10:45 UTC
-
- <% end %> + <% end %> - <% tabs.with_lazy_panel( - id: 'preview-panel-metrics', - tab_id: 'preview-tab-metrics', - frame_id: 'preview-metrics-frame', - src_path: '/lookbook-mocks/tabs/lazy-load/metrics', - selected: false, - ) do %> - Metrics content loads from the mocked Turbo Frame endpoint. - <% end %> + <% tabs.with_lazy_panel( + id: 'preview-panel-metrics', + tab_id: 'preview-tab-metrics', + frame_id: 'preview-metrics-frame', + src_path: '/lookbook-mocks/tabs/lazy-load/metrics', + selected: false, + ) do %> + <%= pathogen_text(variant: :muted) { "Metrics content loads from the mocked Turbo Frame endpoint." } %> + <% end %> - <% tabs.with_lazy_panel( - id: 'preview-panel-timeline', - tab_id: 'preview-tab-timeline', - frame_id: 'preview-timeline-frame', - src_path: '/lookbook-mocks/tabs/lazy-load/timeline', - selected: false, - ) do %> - Timeline content loads from the mocked Turbo Frame endpoint. + <% tabs.with_lazy_panel( + id: 'preview-panel-timeline', + tab_id: 'preview-tab-timeline', + frame_id: 'preview-timeline-frame', + src_path: '/lookbook-mocks/tabs/lazy-load/timeline', + selected: false, + ) do %> + <%= pathogen_text(variant: :muted) { "Timeline content loads from the mocked Turbo Frame endpoint." } %> + <% end %> <% end %> +
+
+ +
+ <%= pathogen_heading(level: 2, class: "mb-2") { "Usage" } %> + + <%= pathogen_supporting(variant: :muted) do %> + Use <%= pathogen_code { "with_lazy_panel" } %> for inactive tabs. Pass + <%= pathogen_code { "frame_id" } %>, <%= pathogen_code { "src_path" } %>, and + <%= pathogen_code { "selected: false" } %> so Turbo fetches panel content when the tab is selected. <% end %> -
-
- The Lookbook demo starts MSW before Turbo boots, so the lazy frame requests are intercepted in the browser without - adding demo-only Rails routes. -
+ <%= pathogen_supporting(variant: :muted, class: "mt-4") do %> + The Lookbook demo starts MSW before Turbo boots, so lazy frame requests are intercepted in the browser without + adding demo-only Rails routes. + <% end %> +
diff --git a/test/components/previews/pathogen/tabs_preview/url_sync.html.erb b/test/components/previews/pathogen/tabs_preview/url_sync.html.erb index f2a057db..ba77e2ab 100644 --- a/test/components/previews/pathogen/tabs_preview/url_sync.html.erb +++ b/test/components/previews/pathogen/tabs_preview/url_sync.html.erb @@ -2,12 +2,12 @@
-

Bookmarkable tabs

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Bookmarkable tabs" } %> -

- Pass sync_url: true to mirror the active tab in the URL hash. Reloading or sharing + <%= pathogen_supporting(variant: :muted, class: "mb-4") do %> + Pass <%= pathogen_code { "sync_url: true" } %> to mirror the active tab in the URL hash. Reloading or sharing the link returns to the same section. Arrow-key navigation updates the hash as well. -

+ <% end %>
<%= render(Pathogen::Tabs.new( @@ -21,7 +21,7 @@ <% tabs.with_tab(id: 'tab-notes', label: 'Notes') %> <% tabs.with_panel(id: 'panel-overview', tab_id: 'tab-overview') do %> -
+
Investigation
INV-2026-003
@@ -31,57 +31,52 @@
Active monitoring
-

- The URL hash should read #tab-overview while this tab is selected. -

+ <%= pathogen_supporting(variant: :muted, class: "mt-3") do %> + The URL hash should read <%= pathogen_code { "#tab-overview" } %> while this tab is selected. + <% end %> <% end %> <% tabs.with_panel(id: 'panel-specimens', tab_id: 'tab-specimens') do %> -

+ <%= pathogen_text(variant: :muted) do %> 12 linked specimens across 4 jurisdictions. Last specimen added 2026-06-08. -

+ <% end %> <% end %> <% tabs.with_panel(id: 'panel-lineage', tab_id: 'tab-lineage') do %> -

- Consensus lineage: Salmonella ST-11 cluster within 3 SNPs. -

+ <%= pathogen_text(variant: :muted) do %> + Consensus lineage: <%= pathogen_code { "Salmonella" } %> ST-11 cluster within 3 SNPs. + <% end %> <% end %> <% tabs.with_panel(id: 'panel-notes', tab_id: 'tab-notes') do %> -

+ <%= pathogen_text(variant: :muted) do %> Epidemiology follow-up scheduled for 2026-06-12. Contact provincial lead before release. -

+ <% end %> <% end %> <% end %>
-

Hash formats

+ <%= pathogen_heading(level: 2, class: "mb-2") { "Hash formats" } %> -
-
    -
  • #tab-specimens — matches a tab id
  • -
  • #panel-lineage — matches a panel id
  • -
  • #tab-2 — zero-based tab index fallback
  • -
-

- Invalid hashes fall back to default_index. Hash updates use - history.replaceState() so routine tab changes do not pollute browser history. -

-
-
- -
-

Implementation

+
+ <%= pathogen_list(variant: :muted) do |list| %> + <% list.with_item do %> + <%= pathogen_code { "#tab-specimens" } %> — matches a tab id + <% end %> + <% list.with_item do %> + <%= pathogen_code { "#panel-lineage" } %> — matches a panel id + <% end %> + <% list.with_item do %> + <%= pathogen_code { "#tab-2" } %> — zero-based tab index fallback + <% end %> + <% end %> -
<%= render Pathogen::Tabs.new(
-  id: "investigation-tabs",
-  label: "Investigation sections",
-  sync_url: true
-) do |tabs| %>
-  ...
-<% end %>
+ <%= pathogen_supporting(variant: :muted, class: "mt-4") do %> + Invalid hashes fall back to <%= pathogen_code { "default_index" } %>. Hash updates use + <%= pathogen_code { "history.replaceState()" } %> so routine tab changes do not pollute browser history. + <% end %> +
From bed4d6d9a3715c67f68d1b1aeb339b926aca35b4 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Tue, 7 Jul 2026 10:19:02 -0500 Subject: [PATCH 08/10] chore(dev): update Procfile and environment settings for port configuration Modified the Procfile to use the PORT environment variable for the web server and updated the default port in the dev script to 3001. Cleared the hosts configuration in development.rb to facilitate secure port forwarding with VSCode. --- demo/Procfile.dev | 2 +- demo/bin/dev | 4 ++-- demo/config/environments/development.rb | 8 ++------ 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/demo/Procfile.dev b/demo/Procfile.dev index 1ab3599d..7e858f59 100644 --- a/demo/Procfile.dev +++ b/demo/Procfile.dev @@ -1,3 +1,3 @@ -web: bin/rails server -p 3001 +web: bin/rails server -p $PORT -b 0.0.0.0 pathogen-css: pnpm --dir .. run build:css:watch tailwindcss: bin/rails tailwindcss:watch diff --git a/demo/bin/dev b/demo/bin/dev index 7e5f3136..b25dcf3c 100755 --- a/demo/bin/dev +++ b/demo/bin/dev @@ -14,8 +14,8 @@ if ! gem list foreman -i --silent; then gem install foreman fi -# Default to port 3000 if not specified -export PORT="${PORT:-3000}" +# Default to port 3001 if not specified +export PORT="${PORT:-3001}" # Let the debug gem allow remote connections, # but avoid loading until `debugger` is called diff --git a/demo/config/environments/development.rb b/demo/config/environments/development.rb index 7db2e987..18fa2c9b 100644 --- a/demo/config/environments/development.rb +++ b/demo/config/environments/development.rb @@ -42,12 +42,8 @@ # Annotate rendered view with file names. config.action_view.annotate_rendered_view_with_filenames = true - config.hosts << 'localhost' - config.hosts << '127.0.0.1' - config.hosts << '::1' - config.hosts.concat( - ENV.fetch('DEMO_ALLOWED_HOSTS', '').split(',').map(&:strip).compact_blank - ) + # the following settings allow us to forward ports securely using vscode remote port forwarding + config.hosts.clear # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true From 86389ac5b68cb3b64de4c349a5bfa54696a1f585 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Mon, 13 Jul 2026 09:34:49 -0500 Subject: [PATCH 09/10] fix(demo): tighten host checks and align MSW pins Default the Lookbook demo to localhost binding and an explicit hosts allowlist, with opt-in LAN / host-check bypass for Remina and remote port forwarding. Pin msw to 2.14.6 so npm, importmap, and the worker match. --- demo/Procfile.dev | 2 +- demo/README.md | 2 +- demo/bin/dev | 4 +++- demo/config/environments/development.rb | 15 +++++++++++++-- package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/demo/Procfile.dev b/demo/Procfile.dev index 7e858f59..ad449ede 100644 --- a/demo/Procfile.dev +++ b/demo/Procfile.dev @@ -1,3 +1,3 @@ -web: bin/rails server -p $PORT -b 0.0.0.0 +web: bin/rails server -p $PORT -b ${BINDING:-127.0.0.1} pathogen-css: pnpm --dir .. run build:css:watch tailwindcss: bin/rails tailwindcss:watch diff --git a/demo/README.md b/demo/README.md index e267b1ce..e4cdb7e7 100644 --- a/demo/README.md +++ b/demo/README.md @@ -24,7 +24,7 @@ This starts Rails on port 3001 plus two CSS watchers via Foreman (Pathogen gem C - **Preview layout** is `app/views/layouts/lookbook_preview.html.erb`, which loads both `pathogen_view_components.css` (gem CSS) and `tailwind.css` (demo CSS). - **Gem CSS (host-app surface)** is `../app/assets/stylesheets/pathogen_view_components.css`. Build with `pnpm --dir .. run build:css` or watch with `pnpm --dir .. run build:css:watch` from the repo root. - **Demo CSS (Lookbook-only)** is compiled from `app/assets/tailwind/application.css` into `app/assets/builds/tailwind.css` by `bin/rails tailwindcss:build` / `bin/rails tailwindcss:watch`. -- **Port 3001** is hardcoded in `Procfile.dev` to avoid conflicts with other local Rails apps. +- **Port 3001** is the default from `bin/dev` (`PORT`); the server binds to `127.0.0.1` by default (`BINDING`). Use `BINDING=0.0.0.0` for LAN access, and `DEMO_ALLOWED_HOSTS` (or `DEMO_DISABLE_HOST_CHECK=1`) when non-local Host headers are required. ## CSS development diff --git a/demo/bin/dev b/demo/bin/dev index b25dcf3c..f9cf9903 100755 --- a/demo/bin/dev +++ b/demo/bin/dev @@ -14,8 +14,10 @@ if ! gem list foreman -i --silent; then gem install foreman fi -# Default to port 3001 if not specified +# Default to port 3001 and localhost-only bind if not specified. +# Set BINDING=0.0.0.0 for LAN access (e.g. Remina) and DEMO_ALLOWED_HOSTS / DEMO_DISABLE_HOST_CHECK as needed. export PORT="${PORT:-3001}" +export BINDING="${BINDING:-127.0.0.1}" # Let the debug gem allow remote connections, # but avoid loading until `debugger` is called diff --git a/demo/config/environments/development.rb b/demo/config/environments/development.rb index 18fa2c9b..d3eff71d 100644 --- a/demo/config/environments/development.rb +++ b/demo/config/environments/development.rb @@ -42,8 +42,19 @@ # Annotate rendered view with file names. config.action_view.annotate_rendered_view_with_filenames = true - # the following settings allow us to forward ports securely using vscode remote port forwarding - config.hosts.clear + # Local defaults plus DEMO_ALLOWED_HOSTS for LAN / remote-forward hostnames. + # Set DEMO_DISABLE_HOST_CHECK=1 only when you need to bypass host authorization entirely + # (e.g. VS Code remote port forwarding edge cases). + if ENV['DEMO_DISABLE_HOST_CHECK'].present? + config.hosts.clear + else + config.hosts << 'localhost' + config.hosts << '127.0.0.1' + config.hosts << '::1' + config.hosts.concat( + ENV.fetch('DEMO_ALLOWED_HOSTS', '').split(',').map(&:strip).compact_blank + ) + end # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true diff --git a/package.json b/package.json index 314b54a0..02f73e64 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "eslint-config-prettier": "^10.0.0", "globals": "^17.7.0", "jsdom": "^29.1.1", - "msw": "^2.14.6", + "msw": "2.14.6", "prettier": "^3.9.5", "tailwindcss": "^4.3.2", "vite": "^7.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd83db2a..d7042832 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: ^29.1.1 version: 29.1.1 msw: - specifier: ^2.14.6 - version: 2.15.0(@types/node@26.1.1) + specifier: 2.14.6 + version: 2.14.6(@types/node@26.1.1) prettier: specifier: ^3.9.5 version: 3.9.5 @@ -79,7 +79,7 @@ importers: version: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(msw@2.14.6(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) packages: @@ -1361,8 +1361,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.15.0: - resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + msw@2.14.6: + resolution: {integrity: sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -2410,13 +2410,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(msw@2.14.6(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.15.0(@types/node@26.1.1) + msw: 2.14.6(@types/node@26.1.1) vite: 7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': @@ -2904,7 +2904,7 @@ snapshots: ms@2.1.3: {} - msw@2.15.0(@types/node@26.1.1): + msw@2.14.6(@types/node@26.1.1): dependencies: '@inquirer/confirm': 6.1.1(@types/node@26.1.1) '@mswjs/interceptors': 0.41.9 @@ -3197,10 +3197,10 @@ snapshots: lightningcss: 1.32.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(msw@2.14.6(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@26.1.1))(vite@7.3.6(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 From d57f1d22e2f96e2117968a1520811bda4b3f0239 Mon Sep 17 00:00:00 2001 From: Josh Adam Date: Mon, 13 Jul 2026 09:38:40 -0500 Subject: [PATCH 10/10] revert(demo): restore 0.0.0.0 bind for Remina LAN testing Keep the MSW 2.14.6 pin; restore all-interfaces binding and hosts.clear so Remina remote testing works again. --- demo/Procfile.dev | 2 +- demo/README.md | 2 +- demo/bin/dev | 4 +--- demo/config/environments/development.rb | 15 ++------------- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/demo/Procfile.dev b/demo/Procfile.dev index ad449ede..7e858f59 100644 --- a/demo/Procfile.dev +++ b/demo/Procfile.dev @@ -1,3 +1,3 @@ -web: bin/rails server -p $PORT -b ${BINDING:-127.0.0.1} +web: bin/rails server -p $PORT -b 0.0.0.0 pathogen-css: pnpm --dir .. run build:css:watch tailwindcss: bin/rails tailwindcss:watch diff --git a/demo/README.md b/demo/README.md index e4cdb7e7..e267b1ce 100644 --- a/demo/README.md +++ b/demo/README.md @@ -24,7 +24,7 @@ This starts Rails on port 3001 plus two CSS watchers via Foreman (Pathogen gem C - **Preview layout** is `app/views/layouts/lookbook_preview.html.erb`, which loads both `pathogen_view_components.css` (gem CSS) and `tailwind.css` (demo CSS). - **Gem CSS (host-app surface)** is `../app/assets/stylesheets/pathogen_view_components.css`. Build with `pnpm --dir .. run build:css` or watch with `pnpm --dir .. run build:css:watch` from the repo root. - **Demo CSS (Lookbook-only)** is compiled from `app/assets/tailwind/application.css` into `app/assets/builds/tailwind.css` by `bin/rails tailwindcss:build` / `bin/rails tailwindcss:watch`. -- **Port 3001** is the default from `bin/dev` (`PORT`); the server binds to `127.0.0.1` by default (`BINDING`). Use `BINDING=0.0.0.0` for LAN access, and `DEMO_ALLOWED_HOSTS` (or `DEMO_DISABLE_HOST_CHECK=1`) when non-local Host headers are required. +- **Port 3001** is hardcoded in `Procfile.dev` to avoid conflicts with other local Rails apps. ## CSS development diff --git a/demo/bin/dev b/demo/bin/dev index f9cf9903..b25dcf3c 100755 --- a/demo/bin/dev +++ b/demo/bin/dev @@ -14,10 +14,8 @@ if ! gem list foreman -i --silent; then gem install foreman fi -# Default to port 3001 and localhost-only bind if not specified. -# Set BINDING=0.0.0.0 for LAN access (e.g. Remina) and DEMO_ALLOWED_HOSTS / DEMO_DISABLE_HOST_CHECK as needed. +# Default to port 3001 if not specified export PORT="${PORT:-3001}" -export BINDING="${BINDING:-127.0.0.1}" # Let the debug gem allow remote connections, # but avoid loading until `debugger` is called diff --git a/demo/config/environments/development.rb b/demo/config/environments/development.rb index d3eff71d..18fa2c9b 100644 --- a/demo/config/environments/development.rb +++ b/demo/config/environments/development.rb @@ -42,19 +42,8 @@ # Annotate rendered view with file names. config.action_view.annotate_rendered_view_with_filenames = true - # Local defaults plus DEMO_ALLOWED_HOSTS for LAN / remote-forward hostnames. - # Set DEMO_DISABLE_HOST_CHECK=1 only when you need to bypass host authorization entirely - # (e.g. VS Code remote port forwarding edge cases). - if ENV['DEMO_DISABLE_HOST_CHECK'].present? - config.hosts.clear - else - config.hosts << 'localhost' - config.hosts << '127.0.0.1' - config.hosts << '::1' - config.hosts.concat( - ENV.fetch('DEMO_ALLOWED_HOSTS', '').split(',').map(&:strip).compact_blank - ) - end + # the following settings allow us to forward ports securely using vscode remote port forwarding + config.hosts.clear # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true
Feature