From 7b6aebd11fba9c38c01287c553014666d450731c Mon Sep 17 00:00:00 2001 From: rookieInTraining Date: Fri, 12 Jun 2026 21:54:44 +0530 Subject: [PATCH 1/7] Added passive mode and load schema options --- frontend/devtools.css | 140 ++++++++++++++++++ frontend/devtools.html | 41 ++++++ js/shared/constants.js | 19 +++ js/shared/logger.js | 13 ++ js/sw/hash-registry.js | 106 ++++++++++++++ js/sw/index.js | 5 + js/sw/interceptor.js | 127 ++++++++++++---- js/sw/messaging.js | 39 ++++- js/ui/index.js | 4 + js/ui/registry-controls.js | 81 ++++++++++ package-lock.json | 14 +- package.json | 5 +- tests/unit/hash-registry.test.js | 159 ++++++++++++++++++++ tests/unit/service-worker.test.js | 236 +++++++++++++++++++++++++++++- 14 files changed, 955 insertions(+), 34 deletions(-) create mode 100644 js/shared/constants.js create mode 100644 js/shared/logger.js create mode 100644 js/sw/hash-registry.js create mode 100644 js/ui/registry-controls.js create mode 100644 tests/unit/hash-registry.test.js diff --git a/frontend/devtools.css b/frontend/devtools.css index b6f7308..1ee3d17 100644 --- a/frontend/devtools.css +++ b/frontend/devtools.css @@ -353,6 +353,146 @@ body { display: block; } +/* ================================================ + Sidebar Sections (Passive Mode, Schema) + ================================================ */ +.sidebar-section { + padding: 10px 8px; + border-top: 1px solid var(--color-border); +} + +.sidebar-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sidebar-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); +} + +.sidebar-section-desc { + margin-top: 4px; + font-size: 11px; + line-height: 1.4; + color: var(--color-text-muted); +} + +/* Toggle switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 32px; + height: 18px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + inset: 0; + background: var(--color-border-strong); + border-radius: var(--radius-full); + cursor: pointer; + transition: background var(--transition-normal); +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 2px; + top: 2px; + background: var(--color-surface); + border-radius: 50%; + box-shadow: var(--shadow-sm); + transition: transform var(--transition-normal); +} + +.toggle-switch input:checked+.toggle-slider { + background: var(--color-primary); +} + +.toggle-switch input:checked+.toggle-slider::before { + transform: translateX(14px); +} + +.toggle-switch input:focus-visible+.toggle-slider { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Registry info row */ +.registry-info { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + font-size: 11px; + color: var(--color-text-secondary); +} + +.btn-link { + border: none; + background: none; + padding: 0; + font-size: 11px; + color: var(--color-primary); + cursor: pointer; +} + +.btn-link:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +.btn-link:disabled { + color: var(--color-text-muted); + cursor: default; + text-decoration: none; +} + +/* Schema form */ +.schema-form { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.schema-url-input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-primary); + font-size: 11px; + font-family: var(--font-mono); +} + +.schema-url-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.schema-status { + margin-top: 6px; + font-size: 11px; + color: var(--color-text-muted); +} + /* ================================================ Buttons ================================================ */ diff --git a/frontend/devtools.html b/frontend/devtools.html index b31c8bc..89ff384 100644 --- a/frontend/devtools.html +++ b/frontend/devtools.html @@ -60,6 +60,47 @@ + + + + + + diff --git a/js/shared/constants.js b/js/shared/constants.js new file mode 100644 index 0000000..ad6e131 --- /dev/null +++ b/js/shared/constants.js @@ -0,0 +1,19 @@ +/** + * Shared constants used across service-worker and UI bundles. + * @module shared/constants + */ + +// ── Interceptor ─────────────────────────────────────────────────── + +export const BOGUS_HASH_SEED = '1234567890'; +export const BASE64_CHUNK_SIZE = 8192; +export const DEFAULT_OPERATION_NAME = 'Anonymous Query'; + +// ── Hash Registry / Passive Mode ────────────────────────────────── + +export const HASH_REGISTRY_STORAGE_KEY = 'apqHashRegistry'; +export const PASSIVE_MODE_STORAGE_KEY = 'apqPassiveMode'; + +// ── Schema ──────────────────────────────────────────────────────── + +export const SCHEMA_STORAGE_KEY = 'apqSchemaUrl'; diff --git a/js/shared/logger.js b/js/shared/logger.js new file mode 100644 index 0000000..6cd7080 --- /dev/null +++ b/js/shared/logger.js @@ -0,0 +1,13 @@ +/** + * Minimal logging utility used by both service-worker and UI bundles. + * `info` and `debug` calls are stripped in production builds via the + * esbuild `pure` config in build-enhanced.js (line 78). + * @module shared/logger + */ + +export const log = { + debug: (...args) => console.debug(...args), + info: (...args) => console.info(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), +}; diff --git a/js/sw/hash-registry.js b/js/sw/hash-registry.js new file mode 100644 index 0000000..c5fba98 --- /dev/null +++ b/js/sw/hash-registry.js @@ -0,0 +1,106 @@ +/** + * Hash registry: maps APQ hashes to their resolved queries. + * In passive mode, the registry resolves queries without contamination. + * @module sw/hash-registry + */ + +import { storageGet, storageSet } from './chrome-api.js'; +import { HASH_REGISTRY_STORAGE_KEY, PASSIVE_MODE_STORAGE_KEY } from '../shared/constants.js'; +import { log } from '../shared/logger.js'; + +const registry = new Map(); +let passiveMode = false; + +export async function initRegistry() { + try { + const result = await storageGet([HASH_REGISTRY_STORAGE_KEY, PASSIVE_MODE_STORAGE_KEY]); + + const stored = result[HASH_REGISTRY_STORAGE_KEY]; + if (stored && typeof stored === 'object') { + for (const [hash, entry] of Object.entries(stored)) { + registry.set(hash, entry); + } + log.info(`Loaded ${registry.size} entries from hash registry`); + } + + passiveMode = !!result[PASSIVE_MODE_STORAGE_KEY]; + } catch (error) { + log.error('Failed to load hash registry:', error); + } +} + +export function registerHash(hash, data) { + if (!hash || typeof hash !== 'string') return; + const wasNew = !registry.has(hash); + registry.set(hash, { + query: data.query || '', + operationName: data.operationName || '', + variables: data.variables || null, + registeredAt: Date.now(), + }); + persistRegistry(); + if (wasNew) broadcastRegistryUpdate(); +} + +export function lookupHash(hash) { + return registry.get(hash) || null; +} + +export function getRegistrySize() { + return registry.size; +} + +export function clearRegistry() { + registry.clear(); + persistRegistry(); + broadcastRegistryUpdate(); +} + +export function getRegistryEntries() { + const entries = {}; + for (const [hash, data] of registry) { + entries[hash] = data; + } + return entries; +} + +export function isPassiveMode() { + return passiveMode; +} + +export async function setPassiveMode(enabled) { + passiveMode = !!enabled; + try { + await storageSet({ [PASSIVE_MODE_STORAGE_KEY]: passiveMode }); + } catch (error) { + log.error('Failed to persist passive mode:', error); + } + broadcastRegistryUpdate(); +} + +async function persistRegistry() { + try { + const obj = {}; + for (const [hash, data] of registry) { + obj[hash] = data; + } + await storageSet({ [HASH_REGISTRY_STORAGE_KEY]: obj }); + } catch (error) { + log.error('Failed to persist hash registry:', error); + } +} + +function broadcastRegistryUpdate() { + try { + chrome.runtime.sendMessage( + { status: 'REGISTRY_UPDATED', size: registry.size, passiveMode }, + () => { + if (chrome.runtime.lastError) { + // No listener β€” DevTools panel not open + } + } + ); + } catch (_) { + /* runtime not available */ + } +} diff --git a/js/sw/index.js b/js/sw/index.js index 1dc1924..495caae 100644 --- a/js/sw/index.js +++ b/js/sw/index.js @@ -8,9 +8,14 @@ import { attachedTabs, restoreDebuggerState } from './debugger-manager.js'; import { removeTabFromStorage, getStoredTabs } from './storage.js'; import { handleFetchRequestPaused } from './interceptor.js'; import { handleMessage, toggleDebuggerFromAction } from './messaging.js'; +import { initRegistry } from './hash-registry.js'; const ACTION_MENU_ID = 'apq-toggle-debugger'; +// Load the persisted hash registry and passive-mode flag on every SW start +// (MV3 service workers are terminated and restarted frequently). +initRegistry(); + // ── Global error handling ───────────────────────────────────────── self.addEventListener('unhandledrejection', (event) => { diff --git a/js/sw/interceptor.js b/js/sw/interceptor.js index 2f3a803..ae92eb1 100644 --- a/js/sw/interceptor.js +++ b/js/sw/interceptor.js @@ -4,8 +4,10 @@ */ import { digestMessage } from './hash.js'; +import { isPassiveMode, lookupHash, registerHash } from './hash-registry.js'; +import { BOGUS_HASH_SEED, BASE64_CHUNK_SIZE, DEFAULT_OPERATION_NAME } from '../shared/constants.js'; -/** Cached bogus hash to avoid recomputing SHA-256('1234567890') on every APQ request. */ +/** Cached bogus hash to avoid recomputing SHA-256 on every APQ request. */ let cachedBogusHash = null; /** @@ -14,63 +16,128 @@ let cachedBogusHash = null; */ async function getBogusHash() { if (!cachedBogusHash) { - cachedBogusHash = await digestMessage('1234567890'); + cachedBogusHash = await digestMessage(BOGUS_HASH_SEED); } return cachedBogusHash; } +/** + * Send an INTERCEPTED message to the DevTools panel. + * Silently ignores the (common) case where no panel is listening. + * @param {object} fields - Message fields merged over the defaults. + */ +function sendIntercepted(fields) { + chrome.runtime.sendMessage( + { + status: 'INTERCEPTED', + operationName: DEFAULT_OPERATION_NAME, + url: '', + query: '', + variables: null, + hash: '', + isAPQ: false, + ...fields, + }, + () => { + if (chrome.runtime.lastError) { + // DevTools panel for this tab might not be open β€” that's fine + } + } + ); +} + /** * Contaminate an APQ payload by replacing the persisted-query hash, * or capture a full-query payload and forward it to the DevTools panel. + * In passive mode, APQ hashes are resolved from the registry instead of + * being contaminated, leaving the request untouched. * @param {object} payload - Parsed request body (single GraphQL operation). * @param {string} requestUrl - The original request URL. * @param {number} tabId - The source tab ID. + * @returns {Promise} True if the payload was modified. */ export async function contaminatePayload(payload, requestUrl, tabId) { try { if (!payload || typeof payload !== 'object') { console.log('Invalid payload format:', payload); - return; + return false; } if (payload.query === undefined && payload.extensions && payload.extensions.persistedQuery) { - // APQ request without query β€” contaminate the hash + const originalHash = payload.extensions.persistedQuery.sha256Hash || ''; + + if (isPassiveMode()) { + // Passive mode β€” resolve the hash from the registry, leave the request untouched + const entry = lookupHash(originalHash); + console.info( + entry ? 'Resolved APQ hash from registry' : 'APQ hash not in registry (passive mode)' + ); + + sendIntercepted({ + operationName: payload.operationName || entry?.operationName || DEFAULT_OPERATION_NAME, + url: requestUrl || '', + query: entry?.query || '', + variables: payload.variables || entry?.variables || null, + hash: originalHash, + isAPQ: true, + tabId, + }); + return false; + } + + // Active mode β€” contaminate the hash to force the full-query retry const hash = await getBogusHash(); if (hash) { payload.extensions.persistedQuery.sha256Hash = hash; console.info('Contaminated APQ request hash'); - } else { - console.error('Failed to generate hash for payload'); + + sendIntercepted({ + operationName: payload.operationName || DEFAULT_OPERATION_NAME, + url: requestUrl || '', + variables: payload.variables || null, + hash: originalHash, + isAPQ: true, + tabId, + }); + return true; } + + console.error('Failed to generate hash for payload'); + return false; } else if (payload.query !== undefined) { - // Full query request β€” capture and send to DevTools + // Full query request β€” capture, register its hash, and send to DevTools console.log('Captured full query request:', payload.operationName); - chrome.runtime.sendMessage( - { - status: 'INTERCEPTED', - operationName: payload.operationName || 'Anonymous Query', - url: requestUrl || '', + const requestHash = payload.extensions?.persistedQuery?.sha256Hash || ''; + if (requestHash) { + registerHash(requestHash, { query: payload.query || '', + operationName: payload.operationName || '', variables: payload.variables || null, - isAPQ: false, - tabId: tabId, - }, - () => { - if (chrome.runtime.lastError) { - // DevTools panel for this tab might not be open β€” that's fine - } - } - ); + }); + } + + sendIntercepted({ + operationName: payload.operationName || DEFAULT_OPERATION_NAME, + url: requestUrl || '', + query: payload.query || '', + variables: payload.variables || null, + hash: requestHash, + isAPQ: false, + tabId, + }); + return false; } else { try { console.log('Payload does not contain query or APQ extensions:', JSON.stringify(payload)); } catch (stringifyError) { console.log('Payload does not contain query or APQ extensions (could not stringify)'); } + return false; } } catch (error) { console.error('Error in contaminatePayload:', error); + return false; } } @@ -82,9 +149,8 @@ export async function contaminatePayload(payload, requestUrl, tabId) { */ function uint8ArrayToBase64(bytes) { let binary = ''; - const chunkSize = 8192; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)); + for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + BASE64_CHUNK_SIZE)); } return btoa(binary); } @@ -108,6 +174,7 @@ function continueRequest(tabId, requestId, postData) { /** * Handle a Fetch.requestPaused debugger event. * Parses the request body, contaminates APQ payloads, and continues the request. + * The request body is only rewritten when a payload was actually modified. * @param {{tabId: number}} source * @param {object} params - Fetch.requestPaused event params. */ @@ -131,15 +198,23 @@ export function handleFetchRequestPaused(source, params) { try { const requestUrl = params.request.url || ''; + let modified = false; if (Array.isArray(reqBody)) { console.log('Request payload is an array'); for (const req of reqBody) { - await contaminatePayload(req, requestUrl, source.tabId); + modified = (await contaminatePayload(req, requestUrl, source.tabId)) || modified; } } else { console.log('Request payload is a JSON element'); - await contaminatePayload(reqBody, requestUrl, source.tabId); + modified = await contaminatePayload(reqBody, requestUrl, source.tabId); + } + + if (!modified) { + // Nothing was changed (full query, passive mode, or unrecognized payload) + // β€” continue with the original body untouched + continueRequest(source.tabId, params.requestId); + return; } // Encode the modified request as base64 (chunked to avoid overflow) diff --git a/js/sw/messaging.js b/js/sw/messaging.js index aeb4c27..1c4e6da 100644 --- a/js/sw/messaging.js +++ b/js/sw/messaging.js @@ -12,6 +12,7 @@ import { attachDebuggerToTab, detachDebuggerFromTab, } from './debugger-manager.js'; +import { getRegistrySize, clearRegistry, isPassiveMode, setPassiveMode } from './hash-registry.js'; /** * Broadcast a status update to all listeners (DevTools panel, popup, etc.). @@ -132,6 +133,17 @@ function validateMessage(message) { return '"getStatus" must be a boolean'; } + // Validate registry message shapes + if (message.getRegistry !== undefined && typeof message.getRegistry !== 'boolean') { + return '"getRegistry" must be a boolean'; + } + if (message.setPassiveMode !== undefined && typeof message.setPassiveMode !== 'boolean') { + return '"setPassiveMode" must be a boolean'; + } + if (message.clearRegistry !== undefined && typeof message.clearRegistry !== 'boolean') { + return '"clearRegistry" must be a boolean'; + } + // Validate tabId when present if (message.tabId !== undefined && typeof message.tabId !== 'number') { return '"tabId" must be a number'; @@ -146,7 +158,12 @@ function validateMessage(message) { */ export function handleMessage(message, sender, sendResponse) { // Ignore internal broadcast messages - if (message && (message.status === 'INTERCEPTED' || message.status === 'ACTION_TOGGLE')) { + if ( + message && + (message.status === 'INTERCEPTED' || + message.status === 'ACTION_TOGGLE' || + message.status === 'REGISTRY_UPDATED') + ) { return false; } @@ -172,6 +189,26 @@ export function handleMessage(message, sender, sendResponse) { await handleDisconnectMessage(message, sendResponse); } else if (message.getStatus === true) { await handleStatusMessage(message, sendResponse); + } else if (message.getRegistry === true) { + sendResponse({ + status: 'SUCCESS', + size: getRegistrySize(), + passiveMode: isPassiveMode(), + }); + } else if (message.setPassiveMode !== undefined) { + await setPassiveMode(message.setPassiveMode); + sendResponse({ + status: 'SUCCESS', + size: getRegistrySize(), + passiveMode: isPassiveMode(), + }); + } else if (message.clearRegistry === true) { + clearRegistry(); + sendResponse({ + status: 'SUCCESS', + size: 0, + passiveMode: isPassiveMode(), + }); } else { sendResponse({ status: 'ERROR', error: 'Invalid message format or empty patterns' }); } diff --git a/js/ui/index.js b/js/ui/index.js index e571baf..4646835 100644 --- a/js/ui/index.js +++ b/js/ui/index.js @@ -15,6 +15,7 @@ import { handleStartError, handleStopSuccess, } from './debugger-controls.js'; +import { initRegistryControls, updateRegistryUI } from './registry-controls.js'; // ── Panel registration ──────────────────────────────────────────── @@ -35,6 +36,7 @@ document.addEventListener('DOMContentLoaded', function () { initRequestList(); initRequestDetail(); initDebuggerControls(); + initRegistryControls(); // ── Message listeners ───────────────────────────────────────── @@ -66,6 +68,8 @@ document.addEventListener('DOMContentLoaded', function () { } catch (_) { /* port may be closed */ } + } else if (message.status === 'REGISTRY_UPDATED') { + updateRegistryUI(message); } else if (message.status === 'ACTION_TOGGLE') { if (message.error) { handleStartError(message.error); diff --git a/js/ui/registry-controls.js b/js/ui/registry-controls.js new file mode 100644 index 0000000..bf31daf --- /dev/null +++ b/js/ui/registry-controls.js @@ -0,0 +1,81 @@ +/** + * Passive mode toggle and hash registry controls in the sidebar. + * @module ui/registry-controls + */ + +import { getElement } from './dom-helpers.js'; +import { showStatusBanner } from './status.js'; + +/** + * Update the registry UI (size counter, clear button, passive-mode toggle) + * from a service-worker payload. + * @param {{size?: number, passiveMode?: boolean}} info + */ +export function updateRegistryUI(info) { + if (!info || typeof info !== 'object') return; + + if (info.size !== undefined) { + const sizeEl = getElement('registry-size'); + if (sizeEl) sizeEl.textContent = info.size; + + const clearBtn = getElement('btn-clear-registry'); + if (clearBtn) clearBtn.disabled = info.size === 0; + } + + if (info.passiveMode !== undefined) { + const toggle = getElement('passive-mode-toggle'); + if (toggle) toggle.checked = info.passiveMode; + } +} + +/** + * Wire up the passive-mode toggle and clear-registry button, + * and fetch the initial registry state from the service worker. + */ +export function initRegistryControls() { + const toggle = getElement('passive-mode-toggle'); + if (toggle) { + toggle.addEventListener('change', () => { + const enabled = toggle.checked; + chrome.runtime.sendMessage({ setPassiveMode: enabled }, (response) => { + if (chrome.runtime.lastError || !response || response.status !== 'SUCCESS') { + toggle.checked = !enabled; + showStatusBanner('Failed to update passive mode', 'error'); + return; + } + updateRegistryUI(response); + showStatusBanner( + enabled + ? 'Passive mode on β€” requests will not be modified' + : 'Passive mode off β€” APQ hashes will be contaminated', + 'success' + ); + }); + }); + } + + const clearBtn = getElement('btn-clear-registry'); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ clearRegistry: true }, (response) => { + if (chrome.runtime.lastError || !response || response.status !== 'SUCCESS') { + showStatusBanner('Failed to clear hash registry', 'error'); + return; + } + updateRegistryUI(response); + showStatusBanner('Hash registry cleared', 'success'); + }); + }); + } + + // Fetch initial registry state + chrome.runtime.sendMessage({ getRegistry: true }, (response) => { + if (chrome.runtime.lastError) { + console.warn('Failed to get registry state:', chrome.runtime.lastError); + return; + } + if (response && response.status === 'SUCCESS') { + updateRegistryUI(response); + } + }); +} diff --git a/package-lock.json b/package-lock.json index c71b6cc..1d25eba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,10 @@ "": { "name": "apq-debugger", "version": "2.0.0", - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "graphql": "^16.14.0" + }, "devDependencies": { "@playwright/test": "^1.58.1", "chokidar": "^3.5.3", @@ -3297,6 +3300,15 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", + "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index e9b0f8d..9ea53ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apq-debugger", - "version": "2.0.0", + "version": "2.0.1", "description": "Chrome DevTools extension for intercepting Apollo Persisted Queries", "main": "build-enhanced.js", "scripts": { @@ -46,5 +46,8 @@ "repository": { "type": "git", "url": "https://github.com/rookieInTraining/apq-debugger.git" + }, + "dependencies": { + "graphql": "^16.14.0" } } diff --git a/tests/unit/hash-registry.test.js b/tests/unit/hash-registry.test.js new file mode 100644 index 0000000..a477173 --- /dev/null +++ b/tests/unit/hash-registry.test.js @@ -0,0 +1,159 @@ +import { + initRegistry, + registerHash, + lookupHash, + getRegistrySize, + getRegistryEntries, + clearRegistry, + isPassiveMode, + setPassiveMode, +} from '../../js/sw/hash-registry.js'; + +describe('Hash Registry', () => { + beforeEach(async () => { + jest.clearAllMocks(); + chrome.runtime.lastError = null; + + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = null; + cb({}); + }); + chrome.storage.local.set.mockImplementation((_items, cb) => { + chrome.runtime.lastError = null; + cb && cb(); + }); + chrome.runtime.sendMessage.mockImplementation((_msg, cb) => cb && cb(null)); + + // Reset module state between tests + clearRegistry(); + await setPassiveMode(false); + jest.clearAllMocks(); + }); + + describe('registerHash / lookupHash', () => { + test('should register and resolve a hash', () => { + registerHash('abc123', { + query: '{ users { id } }', + operationName: 'GetUsers', + variables: { limit: 10 }, + }); + + const entry = lookupHash('abc123'); + expect(entry).not.toBeNull(); + expect(entry.query).toBe('{ users { id } }'); + expect(entry.operationName).toBe('GetUsers'); + expect(entry.variables).toEqual({ limit: 10 }); + expect(getRegistrySize()).toBe(1); + }); + + test('should return null for unknown hash', () => { + expect(lookupHash('does-not-exist')).toBeNull(); + }); + + test('should ignore invalid hashes', () => { + registerHash(null, { query: 'x' }); + registerHash(42, { query: 'x' }); + registerHash('', { query: 'x' }); + expect(getRegistrySize()).toBe(0); + }); + + test('should persist registry to storage on register', () => { + registerHash('abc123', { query: '{ me }' }); + + expect(chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ + apqHashRegistry: expect.objectContaining({ + abc123: expect.objectContaining({ query: '{ me }' }), + }), + }), + expect.any(Function) + ); + }); + + test('should broadcast REGISTRY_UPDATED for new hashes only', () => { + registerHash('abc123', { query: '{ me }' }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ status: 'REGISTRY_UPDATED', size: 1 }), + expect.any(Function) + ); + + jest.clearAllMocks(); + registerHash('abc123', { query: '{ me }' }); // re-register same hash + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('clearRegistry', () => { + test('should empty the registry and broadcast', () => { + registerHash('abc123', { query: '{ me }' }); + jest.clearAllMocks(); + + clearRegistry(); + + expect(getRegistrySize()).toBe(0); + expect(lookupHash('abc123')).toBeNull(); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ status: 'REGISTRY_UPDATED', size: 0 }), + expect.any(Function) + ); + }); + }); + + describe('passive mode', () => { + test('should default to false', () => { + expect(isPassiveMode()).toBe(false); + }); + + test('should persist passive mode flag', async () => { + await setPassiveMode(true); + + expect(isPassiveMode()).toBe(true); + expect(chrome.storage.local.set).toHaveBeenCalledWith( + { apqPassiveMode: true }, + expect.any(Function) + ); + }); + }); + + describe('initRegistry', () => { + test('should load entries and passive mode from storage', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = null; + cb({ + apqHashRegistry: { + stored1: { query: '{ a }', operationName: 'A', variables: null, registeredAt: 1 }, + }, + apqPassiveMode: true, + }); + }); + + await initRegistry(); + + expect(lookupHash('stored1')).toEqual( + expect.objectContaining({ query: '{ a }', operationName: 'A' }) + ); + expect(isPassiveMode()).toBe(true); + }); + + test('should handle storage errors gracefully', async () => { + chrome.storage.local.get.mockImplementation((_keys, cb) => { + chrome.runtime.lastError = { message: 'Storage error' }; + cb({}); + chrome.runtime.lastError = null; + }); + + await expect(initRegistry()).resolves.toBeUndefined(); + }); + }); + + describe('getRegistryEntries', () => { + test('should return a plain object snapshot', () => { + registerHash('h1', { query: '{ a }' }); + registerHash('h2', { query: '{ b }' }); + + const entries = getRegistryEntries(); + expect(Object.keys(entries).sort()).toEqual(['h1', 'h2']); + expect(entries.h1.query).toBe('{ a }'); + }); + }); +}); diff --git a/tests/unit/service-worker.test.js b/tests/unit/service-worker.test.js index bcefa4f..1e55966 100644 --- a/tests/unit/service-worker.test.js +++ b/tests/unit/service-worker.test.js @@ -7,6 +7,12 @@ import { restoreDebuggerState, } from '../../js/sw/debugger-manager.js'; import { contaminatePayload, handleFetchRequestPaused } from '../../js/sw/interceptor.js'; +import { + registerHash, + lookupHash, + clearRegistry, + setPassiveMode, +} from '../../js/sw/hash-registry.js'; import { digestMessage } from '../../js/sw/hash.js'; import { handleMessage, toggleDebuggerFromAction } from '../../js/sw/messaging.js'; import { @@ -211,9 +217,9 @@ describe('Service Worker Logic', () => { }); test('should handle null/invalid payloads gracefully', async () => { - await expect(contaminatePayload(null, '', 1)).resolves.toBeUndefined(); - await expect(contaminatePayload('not-an-object', '', 1)).resolves.toBeUndefined(); - await expect(contaminatePayload(42, '', 1)).resolves.toBeUndefined(); + await expect(contaminatePayload(null, '', 1)).resolves.toBe(false); + await expect(contaminatePayload('not-an-object', '', 1)).resolves.toBe(false); + await expect(contaminatePayload(42, '', 1)).resolves.toBe(false); }); test('should log payload without query or APQ extensions', async () => { @@ -225,6 +231,142 @@ describe('Service Worker Logic', () => { ); spy.mockRestore(); }); + + test('should report the APQ phase with the original hash', async () => { + const payload = { + operationName: 'GetUsers', + extensions: { persistedQuery: { sha256Hash: 'original-hash' } }, + }; + + await contaminatePayload(payload, 'http://example.com/graphql', 7); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'INTERCEPTED', + operationName: 'GetUsers', + hash: 'original-hash', + isAPQ: true, + tabId: 7, + }), + expect.any(Function) + ); + }); + + test('should register hash from full-query retry containing persistedQuery extension', async () => { + clearRegistry(); + const payload = { + query: '{ users { id } }', + operationName: 'GetUsers', + variables: { limit: 10 }, + extensions: { persistedQuery: { sha256Hash: 'retry-hash' } }, + }; + + await contaminatePayload(payload, 'http://example.com/graphql', 1); + + expect(lookupHash('retry-hash')).toEqual( + expect.objectContaining({ + query: '{ users { id } }', + operationName: 'GetUsers', + }) + ); + clearRegistry(); + }); + + test('should report modified=true only when contaminating', async () => { + const apqPayload = { extensions: { persistedQuery: { sha256Hash: 'h' } } }; + const fullPayload = { query: '{ me }' }; + + await expect(contaminatePayload(apqPayload, '', 1)).resolves.toBe(true); + await expect(contaminatePayload(fullPayload, '', 1)).resolves.toBe(false); + await expect(contaminatePayload({ foo: 'bar' }, '', 1)).resolves.toBe(false); + }); + }); + + // ── contaminatePayload (passive mode) ────────────────────────── + + describe('contaminatePayload in passive mode', () => { + beforeEach(async () => { + clearRegistry(); + await setPassiveMode(true); + jest.clearAllMocks(); + }); + + afterEach(async () => { + clearRegistry(); + await setPassiveMode(false); + }); + + test('should not contaminate the hash', async () => { + const payload = { extensions: { persistedQuery: { sha256Hash: 'real-hash' } } }; + + const modified = await contaminatePayload(payload, 'http://x.com/graphql', 1); + + expect(modified).toBe(false); + expect(payload.extensions.persistedQuery.sha256Hash).toBe('real-hash'); + }); + + test('should resolve query from the registry', async () => { + registerHash('known-hash', { + query: '{ users { id } }', + operationName: 'GetUsers', + variables: null, + }); + jest.clearAllMocks(); + + const payload = { extensions: { persistedQuery: { sha256Hash: 'known-hash' } } }; + await contaminatePayload(payload, 'http://x.com/graphql', 3); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'INTERCEPTED', + operationName: 'GetUsers', + query: '{ users { id } }', + hash: 'known-hash', + isAPQ: true, + tabId: 3, + }), + expect.any(Function) + ); + }); + + test('should report unresolved hashes with an empty query', async () => { + const payload = { extensions: { persistedQuery: { sha256Hash: 'unknown-hash' } } }; + await contaminatePayload(payload, 'http://x.com/graphql', 3); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'INTERCEPTED', + query: '', + hash: 'unknown-hash', + isAPQ: true, + }), + expect.any(Function) + ); + }); + + test('handleFetchRequestPaused should continue without rewriting the body', async () => { + const payload = { extensions: { persistedQuery: { sha256Hash: 'real-hash' } } }; + handleFetchRequestPaused( + { tabId: 1 }, + { + requestId: 'rp1', + request: { + hasPostData: true, + postData: JSON.stringify(payload), + url: 'https://api.test.com/graphql', + }, + } + ); + + await new Promise((r) => setTimeout(r, 50)); + + expect(chrome.debugger.sendCommand).toHaveBeenCalledWith( + { tabId: 1 }, + 'Fetch.continueRequest', + { requestId: 'rp1' }, + expect.any(Function) + ); + }); }); // ── attachDebuggerToTab ──────────────────────────────────────── @@ -528,6 +670,65 @@ describe('Service Worker Logic', () => { }) ); }); + + test('should ignore REGISTRY_UPDATED broadcast messages', () => { + const sendResponse = jest.fn(); + const result = handleMessage({ status: 'REGISTRY_UPDATED', size: 3 }, {}, sendResponse); + expect(result).toBe(false); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + test('should handle getRegistry message', async () => { + clearRegistry(); + registerHash('h1', { query: '{ a }' }); + const sendResponse = jest.fn(); + + handleMessage({ getRegistry: true }, {}, sendResponse); + await new Promise((r) => setTimeout(r, 50)); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ status: 'SUCCESS', size: 1, passiveMode: false }) + ); + clearRegistry(); + }); + + test('should handle setPassiveMode message', async () => { + const sendResponse = jest.fn(); + + handleMessage({ setPassiveMode: true }, {}, sendResponse); + await new Promise((r) => setTimeout(r, 50)); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ status: 'SUCCESS', passiveMode: true }) + ); + + await setPassiveMode(false); // reset module state + }); + + test('should reject non-boolean setPassiveMode', () => { + const sendResponse = jest.fn(); + handleMessage({ setPassiveMode: 'yes' }, {}, sendResponse); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'ERROR', + error: expect.stringContaining('setPassiveMode'), + }) + ); + }); + + test('should handle clearRegistry message', async () => { + registerHash('h1', { query: '{ a }' }); + const sendResponse = jest.fn(); + + handleMessage({ clearRegistry: true }, {}, sendResponse); + await new Promise((r) => setTimeout(r, 50)); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ status: 'SUCCESS', size: 0 }) + ); + expect(lookupHash('h1')).toBeNull(); + }); }); // ── toggleDebuggerFromAction ─────────────────────────────────── @@ -628,7 +829,7 @@ describe('Service Worker Logic', () => { ); }); - test('should process valid JSON and continue with modified body', async () => { + test('should continue full-query requests with original body (no rewrite)', async () => { const payload = { query: '{ users { id } }', operationName: 'Test' }; handleFetchRequestPaused( { tabId: 1 }, @@ -644,11 +845,36 @@ describe('Service Worker Logic', () => { await new Promise((r) => setTimeout(r, 50)); + // Full-query requests are not modified, so no postData should be attached + expect(chrome.debugger.sendCommand).toHaveBeenCalledWith( + { tabId: 1 }, + 'Fetch.continueRequest', + { requestId: 'r3' }, + expect.any(Function) + ); + }); + + test('should continue APQ requests with rewritten (contaminated) body', async () => { + const payload = { extensions: { persistedQuery: { sha256Hash: 'real-hash' } } }; + handleFetchRequestPaused( + { tabId: 1 }, + { + requestId: 'r3b', + request: { + hasPostData: true, + postData: JSON.stringify(payload), + url: 'https://api.test.com/graphql', + }, + } + ); + + await new Promise((r) => setTimeout(r, 50)); + expect(chrome.debugger.sendCommand).toHaveBeenCalledWith( { tabId: 1 }, 'Fetch.continueRequest', expect.objectContaining({ - requestId: 'r3', + requestId: 'r3b', postData: expect.any(String), // base64 encoded }), expect.any(Function) From 6ee5649ce8186b616b286aab2e0200c2fb04db36 Mon Sep 17 00:00:00 2001 From: rookieInTraining Date: Fri, 12 Jun 2026 22:51:36 +0530 Subject: [PATCH 2/7] Added responsiveness and keyboard actions to the extension --- frontend/devtools.css | 388 +++++++++++++++++++++++++++--- frontend/devtools.html | 103 +++++++- js/shared/constants.js | 107 ++++++++ js/sw/endpoint-tracker.js | 56 +++++ js/sw/index.js | 2 + js/sw/interceptor.js | 17 ++ js/sw/messaging.js | 57 ++++- js/sw/schema-loader.js | 164 +++++++++++++ js/ui/index.js | 16 ++ js/ui/keyboard.js | 121 ++++++++++ js/ui/layout.js | 68 ++++++ js/ui/request-detail.js | 17 +- js/ui/request-list.js | 31 ++- js/ui/resize.js | 128 ++++++++++ js/ui/schema-controls.js | 158 ++++++++++++ js/ui/schema-viewer.js | 105 ++++++++ js/ui/settings.js | 132 ++++++++++ js/ui/toolbar.js | 83 +++++++ test-results/.last-run.json | 13 +- tests/e2e/extension.spec.js | 64 ++++- tests/unit/devtools.test.js | 295 +++++++++++++++++++++++ tests/unit/schema-loader.test.js | 207 ++++++++++++++++ tests/unit/service-worker.test.js | 67 ++++++ 23 files changed, 2337 insertions(+), 62 deletions(-) create mode 100644 js/sw/endpoint-tracker.js create mode 100644 js/sw/schema-loader.js create mode 100644 js/ui/keyboard.js create mode 100644 js/ui/layout.js create mode 100644 js/ui/resize.js create mode 100644 js/ui/schema-controls.js create mode 100644 js/ui/schema-viewer.js create mode 100644 js/ui/settings.js create mode 100644 js/ui/toolbar.js create mode 100644 tests/unit/schema-loader.test.js diff --git a/frontend/devtools.css b/frontend/devtools.css index 1ee3d17..27a916d 100644 --- a/frontend/devtools.css +++ b/frontend/devtools.css @@ -39,11 +39,56 @@ --transition-fast: 0.15s ease; --transition-normal: 0.2s ease; + + /* Density (overridden by [data-density="compact"]) */ + --font-size-base: 13px; + --header-height: 40px; + --control-pad-y: 8px; + --item-pad-y: 10px; + --panel-pad: 12px; + --section-pad: 10px; +} + +/* Compact density */ +:root[data-density="compact"] { + --font-size-base: 12px; + --header-height: 32px; + --control-pad-y: 5px; + --item-pad-y: 5px; + --panel-pad: 8px; + --section-pad: 6px; +} + +/* Dark Mode. + The variable block is intentionally duplicated: once for the explicit + user override ([data-theme="dark"]) and once for system preference + (when no explicit "light" override is set). */ + :root[data-theme="dark"] { + --color-primary: #6366F1; + --color-primary-hover: #818CF8; + --color-success: #22C55E; + --color-success-bg: rgba(34, 197, 94, 0.15); + --color-warning: #F59E0B; + --color-warning-bg: rgba(245, 158, 11, 0.15); + --color-error: #EF4444; + --color-error-bg: rgba(239, 68, 68, 0.15); + --color-surface: #1E1E2E; + --color-surface-alt: #2A2A3E; + --color-surface-hover: #3A3A4E; + --color-border: #3A3A4E; + --color-border-strong: #4A4A5E; + --color-text-primary: #E4E4E7; + --color-text-secondary: #A1A1AA; + --color-text-muted: #71717A; + --color-text-inverse: #18181B; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5); } /* Dark Mode */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --color-primary: #6366F1; --color-primary-hover: #818CF8; --color-success: #22C55E; @@ -79,7 +124,7 @@ body { font-family: var(--font-sans); - font-size: 13px; + font-size: var(--font-size-base); line-height: 1.5; color: var(--color-text-primary); background: var(--color-surface); @@ -102,7 +147,7 @@ body { display: flex; align-items: center; justify-content: space-between; - height: 40px; + height: var(--header-height); padding: 0 12px; background: var(--color-surface-alt); border-bottom: 1px solid var(--color-border); @@ -134,6 +179,7 @@ body { .header-right { display: flex; align-items: center; + gap: 8px; } /* Status Badge */ @@ -233,7 +279,10 @@ body { } .sidebar-header { - padding: 12px; + display: flex; + align-items: center; + gap: 6px; + padding: var(--panel-pad); border-bottom: 1px solid var(--color-border); } @@ -260,7 +309,8 @@ body { .urlPattern { flex: 1; - padding: 8px 10px; + min-width: 0; + padding: var(--control-pad-y) 10px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); @@ -357,7 +407,7 @@ body { Sidebar Sections (Passive Mode, Schema) ================================================ */ .sidebar-section { - padding: 10px 8px; + padding: var(--section-pad) 8px; border-top: 1px solid var(--color-border); } @@ -501,7 +551,7 @@ body { align-items: center; justify-content: center; gap: 6px; - padding: 8px 16px; + padding: var(--control-pad-y) 16px; border: none; border-radius: var(--radius-md); font-size: 12px; @@ -585,7 +635,7 @@ body { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px; + padding: var(--section-pad) var(--panel-pad); border-bottom: 1px solid var(--color-border); gap: 12px; flex-wrap: wrap; @@ -664,7 +714,7 @@ body { display: flex; align-items: center; gap: 10px; - padding: 10px 12px; + padding: var(--item-pad-y) 12px; border-radius: var(--radius-md); cursor: pointer; transition: background var(--transition-fast); @@ -783,7 +833,7 @@ body { .detail-content { flex: 1; overflow-y: auto; - padding: 12px; + padding: var(--panel-pad); } .detail-placeholder { @@ -1068,28 +1118,310 @@ body.resizing * { } /* ================================================ - Responsive - Narrow DevTools + Inline SVG Icons ================================================ */ -@media (max-width: 600px) { - .sidebar { - width: 180px; - min-width: 150px; - } +.icon { + width: 14px; + height: 14px; + flex-shrink: 0; + vertical-align: -2px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} - .detail-panel { - width: 280px; - min-width: 200px; - } +.empty-icon .icon { + width: 42px; + height: 42px; + stroke-width: 1.2; +} - .filter-input { - width: 120px; - } +.panel-title .icon, +.sidebar-title .icon { + margin-right: 4px; +} - .panel-toolbar { - display: none; - } +/* ================================================ + Header Buttons & Settings Popover + ================================================ */ +.btn-header { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all var(--transition-fast); +} + +.btn-header:hover, +.btn-header[aria-expanded="true"] { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +.settings-wrap { + position: relative; +} + +.settings-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 100; + width: 220px; + padding: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.settings-popover.hidden { + display: none; +} + +.settings-group+.settings-group { + margin-top: 12px; +} + +.settings-group-label { + display: block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + margin-bottom: 6px; +} + +.settings-options { + display: flex; + gap: 4px; +} + +.settings-option { + flex: 1; +} + +.settings-option input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.settings-option span { + display: block; + padding: 5px 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-align: center; + font-size: 11px; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-option input:checked+span { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-text-inverse); +} + +.settings-option input:focus-visible+span { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* ================================================ + Detail Panel Tabs (Request / Schema) + ================================================ */ +.detail-tabs { + display: flex; + gap: 2px; + padding: 2px; + border-radius: var(--radius-md); + background: var(--color-surface-alt); +} + +.detail-tab { + padding: 3px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.detail-tab[aria-selected="true"] { + background: var(--color-surface); + color: var(--color-text-primary); + box-shadow: var(--shadow-sm); +} + +.detail-tab:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ================================================ + Schema Viewer + ================================================ */ +.schema-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.schema-content.hidden { + display: none; +} + +.schema-toolbar { + display: flex; + gap: 6px; + padding: 8px var(--panel-pad); + border-bottom: 1px solid var(--color-border); +} + +.schema-search { + flex: 1; + min-width: 0; + padding: 5px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text-primary); + font-size: 12px; +} + +.schema-search:focus { + outline: none; + border-color: var(--color-primary); +} - .resize-handle { - width: 3px; +.schema-types { + flex: 1; + overflow-y: auto; + padding: var(--panel-pad); +} + +.schema-type-block { + margin-bottom: 10px; +} + +.schema-type-block pre { + margin: 0; + padding: 8px 10px; + border-radius: var(--radius-md); + background: var(--color-surface-alt); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.55; + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.schema-meta { + padding: 6px var(--panel-pad); + font-size: 11px; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + word-break: break-all; +} + +.schema-empty { + padding: 24px; + text-align: center; + font-size: 12px; + color: var(--color-text-muted); +} + +/* ================================================ + Responsive Layout (class-driven, set by layout.js) + ================================================ */ +.main-content { + position: relative; +} + +/* Manual sidebar collapse (any width) */ +.app.sidebar-collapsed .sidebar, +.app.sidebar-collapsed #resize-handle-left { + display: none; +} + +/* Narrow: detail panel becomes an overlay */ +.app.narrow .detail-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 20; + width: min(85%, 360px); + border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); +} + +.app.narrow #resize-handle-right { + display: none; +} + +.app.narrow .request-panel { + min-width: 0; +} + +/* Very narrow: sidebar becomes a slide-in drawer */ +.app.very-narrow .sidebar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + z-index: 30; + width: min(85%, 260px); + border-right: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + transform: translateX(-105%); + transition: transform var(--transition-normal); +} + +.app.very-narrow.sidebar-open .sidebar { + transform: none; +} + +.app.very-narrow #resize-handle-left { + display: none; +} + +.app.very-narrow .filter-input { + width: 110px; +} + +.app.very-narrow .header-title { + display: none; +} + +/* ================================================ + Reduced Motion + ================================================ */ +@media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; } } \ No newline at end of file diff --git a/frontend/devtools.html b/frontend/devtools.html index 89ff384..adfc62b 100644 --- a/frontend/devtools.html +++ b/frontend/devtools.html @@ -13,6 +13,13 @@ @@ -36,7 +87,10 @@