Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh', 'jsx-a11y'],
plugins: ['jsx-a11y'],
settings: { react: { version: '18.3' } },
rules: {
'react/react-in-jsx-scope': 'off',
Expand Down
54 changes: 38 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ A Chrome extension for inspecting, editing, saving, and replaying URL query para
## Features

- **Live parameter editor** — parses the active tab's URL, shows each query param as an editable row, with one-click navigation to the edited URL.
- **Type-aware inputs** — `true` / `false` values render as a toggle switch with a `BOOL` badge; everything else is a monospace text field.
- **Highlighted URL preview** — full URL shown at the top with parameter keys in the accent color, base URL and separators muted.
- **Type-aware inputs** — `true` / `false` values render as a toggle switch next to the literal value (original casing preserved, so `TRUE` toggles to `FALSE`); JSON objects and arrays get a drill-down editor; everything else is a monospace text field.
- **Filter parameters** — live substring search over parameter keys, for URLs with dozens of params.
- **Highlighted URL preview** — full URL shown at the top with parameter keys emphasized and separators muted; editable directly.
- **Structured JSON editing** — drill into nested objects and arrays with a breadcrumb stack, or edit the raw JSON.
- **Save & group** — persist URLs into named groups (stored locally via `chrome.storage.local`), reload them into the editor with one click.
- **Copy / Apply / Reset** — copy the current URL, replace the active tab's URL, or snap back to the URL that was open when the popup first loaded.
- **Dark, light, and system themes** — all colors meet WCAG 2.2 AA contrast.
- **Accent color** — Settings offers ten accent swatches that tint the logo, parameter badge, URL keys, toggles and primary buttons; "Reset to monochrome" returns to the base look. Persisted locally.
- **Follows your system appearance** — light and dark switch with the OS, live. There is no theme setting to get out of sync.
- **Full keyboard navigation** — every interactive element is reachable via Tab, with visible focus rings and sensible shortcuts.
- **Screen-reader friendly** — semantic HTML, ARIA live region for status announcements, role-correct toggle/switch/dialog semantics.
- **Screen-reader friendly** — semantic HTML, a single ARIA live region (the toast doubles as it, so nothing is announced twice), role-correct toggle/switch/radiogroup/dialog semantics.

## Planned features

Expand All @@ -28,28 +31,26 @@ A Chrome extension for inspecting, editing, saving, and replaying URL query para
| `Enter` in a row field | Commit / advance focus |
| `Cmd`/`Ctrl` + `Enter` | Apply edited URL to the active tab |
| `Cmd`/`Ctrl` + `S` | Open the saved URLs drawer |
| `Esc` | Close the drawer |
| `Esc` | Close the open panel / step one level out of an expanded JSON value |
| `Space` on a `BOOL` toggle | Flip the value |
| `←` / `→` on the theme switch | Cycle light → system → dark |

## Tech stack

- **Manifest V3** (service worker, `action`, minimal permissions)
- **React 18** with TypeScript (strict mode)
- **Vite** + `@crxjs/vite-plugin` for HMR during popup development
- **Zustand** for state (small footprint, test-friendly)
- **CSS Modules** + CSS custom properties for theming
- **Vitest** + React Testing Library (infrastructure ready; tests not yet written)
- **CSS Modules** + CSS custom properties for theming, with an `oklch` monochrome token set in `src/styles/tokens.css`
- **Vitest** + React Testing Library (unit tests cover URL parsing, param typing, structured values, storage, and the store)

## Permissions

The extension requests only what it needs:

| Permission | Why |
| --- | --- |
| `activeTab` | Read and update the URL of the currently focused tab |
| `tabs` | Query the active tab's URL when the popup opens |
| `storage` | Persist saved links, groups, and theme preference locally |
| `activeTab` | Read and update the URL of the currently focused tab (granted per user gesture — no standing access to browsing history) |
| `storage` | Persist saved links, groups, and the accent color locally |

No `<all_urls>`, no `clipboardWrite` (popups are secure contexts and a user-gesture click is enough for `navigator.clipboard.writeText`).

Expand Down Expand Up @@ -135,15 +136,36 @@ The following secrets must be set in the repository settings:
2. Open a PR from `main` → `release`.
3. Merge the PR — the workflow bumps the version, builds, zips, uploads to the store, and tags the release automatically.

## Design system

The UI follows a monochrome, Linear-inspired design (see `src/styles/tokens.css`).

| Aspect | Value |
| --- | --- |
| Popup size | 380 × 600 px |
| Palette | `oklch` with zero chroma — hierarchy from lightness, border alpha, and weight |
| Radii | 6–7px controls · 8px larger inputs · 10px cards · 20px pills |
| Type | Inter (UI) · JetBrains Mono (URLs and values) |
| Icons | Inline Feather/Lucide-style outlines: 24-unit viewBox, 2px stroke, round caps |

Three deliberate deviations from the design handoff:

- **Height is 600px, not 640px.** Chrome hard-caps extension popups at 800 × 600; a 640px panel is simply clipped. The layout is a flex column with a scrolling middle band, so it renders faithfully at 600.
- **Reset does not open a `window.confirm()` dialog.** Modal JS dialogs are suppressed inside MV3 extension popups, so the confirmation would never appear and Reset would silently do nothing. The handoff lists this as a tweakable behavior; it is off.
- **Accent labels are not fixed to near-white.** The handoff specifies `#FAFAFA` text on the accent-colored buttons, but every swatch in the palette fails WCAG AA that way — from 3.4:1 (Violet) down to 2.2:1 (Lime), against a 4.5:1 requirement for 13px text. The label color is therefore chosen per swatch by luminance, and accent-as-text (logo, badge, URL keys) is darkened or lightened just enough to clear 4.5:1 on the current panel. Solid fills keep the exact swatch the user picked, so a button always matches its swatch. See `src/lib/contrast.ts`.

Fonts are referenced by family name and fall back to the system stack — the extension ships no webfonts, because the MV3 content security policy blocks remote font CDNs. To match the reference typography exactly, self-host the Inter and JetBrains Mono `woff2` files under `public/` and add an `@font-face` block.

## Accessibility notes

- Every interactive element is ≥ 28×28 CSS px (WCAG 2.2 SC 2.5.8).
- Focus indicators are preserved via `:focus-visible` outlines; `outline: none` is never used without a replacement.
- Interactive controls are ≥ 24×24 CSS px (WCAG 2.2 SC 2.5.8 minimum), with header and footer actions at 28px and larger.
- The reference design signals focus only by lightening an input border. That is kept, and a `:focus-visible` outline is layered on top so keyboard users get a real indicator; `outline: none` is never used without a replacement.
- Every input has a `<label>` (visible or `.visually-hidden`), never bare placeholders as labels.
- The drawer uses `role="dialog"`, `aria-modal="true"`, and a focus trap that restores focus to the trigger on close.
- Status changes (URL applied, copied, saved, reset, param added/removed) are announced via a single polite `aria-live` region.
- The drawer uses `role="dialog"`, `aria-modal="true"`, and a focus trap that restores focus to the trigger on close. While closed it is `visibility: hidden`, which keeps it out of both the tab order and the accessibility tree.
- Status changes (URL applied, copied, saved, reset, param added/removed) are announced via a single polite `aria-live` region — the visible toast *is* that region, so nothing is announced twice.
- `prefers-reduced-motion` is honored globally — animations collapse to a near-instant transition.
- Color is never the sole channel for status; danger tone is always paired with an icon and label.
- Color is never the sole channel for status: the boolean switch always renders its literal value next to it, and destructive actions pair tone with an icon and label.
- `color-scheme` is declared per theme so Chrome does not apply its own auto-dark-mode inversion to form controls, and native scrollbars and select popups match the theme.

## Testability

Expand Down
124 changes: 124 additions & 0 deletions src/__tests__/contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import {
adjustForContrast,
contrastRatio,
luminance,
readableOn,
withAlpha,
} from '@/lib/contrast';
import { ACCENTS, isKnownAccent } from '@/lib/accents';

const WHITE_PANEL = '#ffffff';
const DARK_PANEL = '#262626';
const AA_NORMAL = 4.5;

describe('luminance', () => {
it('is 0 for black and 1 for white', () => {
expect(luminance('#000000')).toBeCloseTo(0, 5);
expect(luminance('#ffffff')).toBeCloseTo(1, 5);
});
});

describe('contrastRatio', () => {
it('is 21 for black on white', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 1);
});

it('is 1 for identical colors', () => {
expect(contrastRatio('#6690e0', '#6690e0')).toBeCloseTo(1, 5);
});

it('is symmetric', () => {
expect(contrastRatio('#6690e0', '#ffffff')).toBeCloseTo(
contrastRatio('#ffffff', '#6690e0'),
5,
);
});
});

describe('readableOn', () => {
it('picks a light label on a dark background', () => {
expect(contrastRatio(readableOn('#141414'), '#141414')).toBeGreaterThanOrEqual(AA_NORMAL);
});

it('picks a dark label on a light background', () => {
expect(contrastRatio(readableOn('#fafafa'), '#fafafa')).toBeGreaterThanOrEqual(AA_NORMAL);
});

it('gives every accent swatch an AA-readable label', () => {
for (const { name, hex } of ACCENTS) {
const ratio = contrastRatio(readableOn(hex), hex);
expect(ratio, `${name} label contrast`).toBeGreaterThanOrEqual(AA_NORMAL);
}
});
});

describe('adjustForContrast', () => {
it('leaves a color alone when it already passes', () => {
// Black on white is already 21:1 — no adjustment needed.
expect(adjustForContrast('#000000', WHITE_PANEL)).toBe('#000000');
});

it('brings every accent to AA as text on both panels', () => {
for (const { name, hex } of ACCENTS) {
const onLight = adjustForContrast(hex, WHITE_PANEL);
const onDark = adjustForContrast(hex, DARK_PANEL);
expect(contrastRatio(onLight, WHITE_PANEL), `${name} on light`).toBeGreaterThanOrEqual(
AA_NORMAL,
);
expect(contrastRatio(onDark, DARK_PANEL), `${name} on dark`).toBeGreaterThanOrEqual(
AA_NORMAL,
);
}
});

it('darkens toward black on a light background', () => {
const adjusted = adjustForContrast('#8db84f', WHITE_PANEL);
expect(luminance(adjusted)).toBeLessThan(luminance('#8db84f'));
});

it('lightens toward white on a dark background', () => {
const adjusted = adjustForContrast('#3a3a3a', DARK_PANEL);
expect(luminance(adjusted)).toBeGreaterThan(luminance('#3a3a3a'));
});
});

describe('withAlpha', () => {
it('appends an 8-digit hex alpha', () => {
expect(withAlpha('#6690e0', 0.15)).toBe('#6690e026');
});

it('clamps out-of-range alpha', () => {
expect(withAlpha('#6690e0', 2)).toBe('#6690e0ff');
expect(withAlpha('#6690e0', -1)).toBe('#6690e000');
});
});

describe('ACCENTS', () => {
it('has the ten swatches from the handoff', () => {
expect(ACCENTS).toHaveLength(10);
});

it('uses lowercase 6-digit hex so storage comparisons are stable', () => {
for (const { hex } of ACCENTS) expect(hex).toMatch(/^#[0-9a-f]{6}$/);
});

it('has no duplicate colors', () => {
expect(new Set(ACCENTS.map((a) => a.hex)).size).toBe(ACCENTS.length);
});
});

describe('isKnownAccent', () => {
it('accepts a palette color', () => {
expect(isKnownAccent('#6690e0')).toBe(true);
});

it('is case-insensitive', () => {
expect(isKnownAccent('#6690E0')).toBe(true);
});

it('rejects anything else', () => {
expect(isKnownAccent('#123456')).toBe(false);
expect(isKnownAccent('red')).toBe(false);
});
});
19 changes: 18 additions & 1 deletion src/__tests__/paramTypes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { describe, it, expect } from 'vitest';
import { detectParamType } from '@/lib/paramTypes';
import { detectParamType, flipBoolean } from '@/lib/paramTypes';

describe('flipBoolean', () => {
it('flips lowercase', () => {
expect(flipBoolean('true')).toBe('false');
expect(flipBoolean('false')).toBe('true');
});

it('preserves all-caps casing', () => {
expect(flipBoolean('TRUE')).toBe('FALSE');
expect(flipBoolean('FALSE')).toBe('TRUE');
});

it('preserves capitalized casing', () => {
expect(flipBoolean('True')).toBe('False');
expect(flipBoolean('False')).toBe('True');
});
});

describe('detectParamType', () => {
describe('boolean', () => {
Expand Down
33 changes: 14 additions & 19 deletions src/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,31 +87,26 @@ describe('storage.setGroups', () => {
});
});

describe('storage.getTheme', () => {
it('returns "system" as the default theme', async () => {
const theme = await storage.getTheme();
expect(theme).toBe('system');
describe('storage.getAccent', () => {
it('returns null by default (monochrome)', async () => {
expect(await storage.getAccent()).toBeNull();
});

it('returns stored theme', async () => {
await storage.setTheme('dark');
expect(await storage.getTheme()).toBe('dark');
it('returns the stored accent', async () => {
await storage.setAccent('#6690e0');
expect(await storage.getAccent()).toBe('#6690e0');
});
});

describe('storage.setTheme', () => {
it('persists light theme', async () => {
await storage.setTheme('light');
expect(await storage.getTheme()).toBe('light');
describe('storage.setAccent', () => {
it('persists an accent hex', async () => {
await storage.setAccent('#4fa98e');
expect(await storage.getAccent()).toBe('#4fa98e');
});

it('persists dark theme', async () => {
await storage.setTheme('dark');
expect(await storage.getTheme()).toBe('dark');
});

it('persists system theme', async () => {
await storage.setTheme('system');
expect(await storage.getTheme()).toBe('system');
it('clears back to monochrome', async () => {
await storage.setAccent('#4fa98e');
await storage.setAccent(null);
expect(await storage.getAccent()).toBeNull();
});
});
10 changes: 10 additions & 0 deletions src/__tests__/structuredParam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,14 @@ describe('renameKeyAtPath', () => {
renameKeyAtPath(root, [], 'a', 'b');
expect(root).toEqual({ a: 1 });
});

it('refuses to rename onto an existing key (would lose a value)', () => {
const root = { a: 1, b: 2 };
expect(renameKeyAtPath(root, [], 'a', 'b')).toBe(root);
});

it('refuses collision at a nested path', () => {
const root = { nested: { x: 10, y: 20 } };
expect(renameKeyAtPath(root, ['nested'], 'x', 'y')).toBe(root);
});
});
31 changes: 31 additions & 0 deletions src/__tests__/urlParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,37 @@ describe('parseUrl', () => {
const result = parseUrl('https://example.com/?q=hello%20world');
expect(result.params[0].value).toBe('hello world');
});

it('keeps the real query string when a hash query is also present', () => {
const result = parseUrl('https://app.com/path?main=1#/route?foo=bar');
expect(result.hashQuery).toBe(true);
expect(result.base).toBe('https://app.com/path?main=1');
expect(result.params).toHaveLength(1);
expect(result.params[0]).toMatchObject({ key: 'foo', value: 'bar' });
// Round trip must not drop ?main=1.
expect(serializeUrl(result)).toBe('https://app.com/path?main=1#/route?foo=bar');
expect(serializeUrlForNav(result)).toBe('https://app.com/path?main=1#/route?foo=bar');
});

it('does not treat an anchor ending in "?" as a hash query', () => {
const result = parseUrl('https://en.wikipedia.org/wiki/Foo#Why?');
expect(result.hashQuery).toBe(false);
expect(result.fragment).toBe('#Why?');
expect(serializeUrl(result)).toBe('https://en.wikipedia.org/wiki/Foo#Why?');
});

it('builds a correct base for file: URLs', () => {
const result = parseUrl('file:///tmp/page.html?x=1');
expect(result.base).toBe('file:///tmp/page.html');
expect(result.params[0]).toMatchObject({ key: 'x', value: '1' });
expect(serializeUrl(result)).toBe('file:///tmp/page.html?x=1');
});

it('preserves credentials and port in the base', () => {
const result = parseUrl('https://user:pass@example.com:8080/x?a=1');
expect(result.base).toBe('https://user:pass@example.com:8080/x');
expect(serializeUrl(result)).toBe('https://user:pass@example.com:8080/x?a=1');
});
});

describe('serializeUrl', () => {
Expand Down
29 changes: 29 additions & 0 deletions src/__tests__/useAppStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ describe('setCurrentUrl', () => {
useAppStore.getState().setCurrentUrl('totally invalid');
expect(useAppStore.getState().currentParsed).toEqual(before);
});

it('keeps param ids stable across re-parses when position+key survive', () => {
useAppStore.getState().loadUrl('https://example.com/?a=1&b=2', 1);
const [a, b] = useAppStore.getState().currentParsed!.params;
// Simulate typing in the URL field: value of `a` changed, keys unchanged.
useAppStore.getState().setCurrentUrl('https://example.com/?a=99&b=2');
const after = useAppStore.getState().currentParsed!.params;
expect(after[0].id).toBe(a.id);
expect(after[0].value).toBe('99');
expect(after[1].id).toBe(b.id);
});

it('assigns a fresh id when the key at a position changes', () => {
useAppStore.getState().loadUrl('https://example.com/?a=1', 1);
const [a] = useAppStore.getState().currentParsed!.params;
useAppStore.getState().setCurrentUrl('https://example.com/?renamed=1');
const after = useAppStore.getState().currentParsed!.params;
expect(after[0].id).not.toBe(a.id);
});
});

describe('updateParamKey', () => {
Expand Down Expand Up @@ -135,6 +154,16 @@ describe('toggleBooleanParam', () => {
it('does nothing when currentParsed is null', () => {
expect(() => useAppStore.getState().toggleBooleanParam('x')).not.toThrow();
});

it('preserves the original casing when toggling', () => {
useAppStore.getState().loadUrl('https://example.com/?a=TRUE&b=False', 1);
const [a, b] = useAppStore.getState().currentParsed!.params;
useAppStore.getState().toggleBooleanParam(a.id);
useAppStore.getState().toggleBooleanParam(b.id);
const after = useAppStore.getState().currentParsed!.params;
expect(after[0].value).toBe('FALSE');
expect(after[1].value).toBe('True');
});
});

describe('removeParam', () => {
Expand Down
Loading
Loading