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
79 changes: 42 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

A Chrome extension for inspecting, editing, saving, and replaying URL query parameters on the active tab. Built for developers and QA engineers.

**[Install from the Chrome Web Store](https://chromewebstore.google.com/detail/querycraft/gokkgpehjnmdcnpknmkefbjnhpnnhbih)**

![QueryCraft popup](public/icons/icon128.png)

## Features
Expand All @@ -15,6 +17,10 @@ A Chrome extension for inspecting, editing, saving, and replaying URL query para
- **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.

## Planned features

- **Company-specific query filter rules** — support for complex, configurable filters tailored to the needs of individual companies. The configuration delivery mechanism is still being explored (inline config, file import, or remote fetch). Currently available as a **beta** on the [`DLS-beta`](../../tree/DLS-beta) branch — build it yourself to try it out.

## Keyboard shortcuts

| Shortcut | Action |
Expand Down Expand Up @@ -91,44 +97,43 @@ npm run lint # ESLint with react + jsx-a11y rules
npm run test # Vitest (jsdom, with chrome.* mocks preconfigured)
```

## Project structure
## Deployment

```
querycraft/
├── manifest.json
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── popup/ # React entry: App.tsx, main.tsx, index.html
│ ├── background/ # Service worker (stateless)
│ ├── components/ # One folder per component (.tsx + .module.css + index.ts)
│ │ ├── Header/
│ │ ├── UrlPreview/
│ │ ├── ParamList/
│ │ ├── ParamRow/
│ │ ├── ParamKeyInput/
│ │ ├── ParamValueInput/
│ │ ├── BooleanToggle/
│ │ ├── AddParamRow/
│ │ ├── RemoveParamButton/
│ │ ├── ActionBar/
│ │ ├── Button/
│ │ ├── IconButton/
│ │ ├── SavedLinksDrawer/
│ │ ├── GroupSelector/
│ │ ├── ThemeToggle/
│ │ ├── LiveRegion/
│ │ ├── EmptyState/
│ │ └── icons.tsx # Shared inline SVGs
│ ├── hooks/ # useActiveTabUrl, useClipboard, useTheme,
│ │ # useSavedLinks, useFocusTrap, useKeyboardShortcuts
│ ├── lib/ # Pure logic: urlParser, paramTypes, storage, tabs
│ ├── store/ # Zustand store
│ ├── types/ # Shared TypeScript types
│ ├── styles/ # tokens.css (light + dark), global.css
│ └── test/ # Vitest setup + chrome mocks
└── public/icons/ # 16 / 48 / 128 PNGs
```
Releases are automated via GitHub Actions and publish directly to the Chrome Web Store.

### How it works

| Trigger | What happens |
| --- | --- |
| PR merged into `release` branch | Automatic deploy |
| Manual `workflow_dispatch` | Deploy with a chosen version bump |

### Version bumping

The workflow bumps `manifest.json` version automatically before building:

| Type | Example |
| --- | --- |
| `patch` (default) | `1.0.0` → `1.0.1` |
| `minor` | `1.0.0` → `1.1.0` |
| `major` | `1.0.0` → `2.0.0` |

### Required secrets

The following secrets must be set in the repository settings:

| Secret | Description |
| --- | --- |
| `EXTENSION_ID` | Chrome Web Store extension ID |
| `CLIENT_ID` | Google OAuth client ID |
| `CLIENT_SECRET` | Google OAuth client secret |
| `REFRESH_TOKEN` | Google OAuth refresh token |

### Release flow

1. Merge your feature branch into `main`.
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.

## Accessibility notes

Expand Down
6 changes: 1 addition & 5 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@
"service_worker": "src/background/service-worker.ts",
"type": "module"
},
"permissions": [
"tabs",
"activeTab",
"storage"
],
"permissions": ["activeTab", "storage"],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
Expand Down
4 changes: 2 additions & 2 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { IconLogo } from '../icons';
import { IconSearch } from '../icons';
import styles from './Header.module.css';

interface HeaderProps {
Expand All @@ -12,7 +12,7 @@ export function Header({ actions }: HeaderProps) {
<header className={styles.root}>
<div className={styles.brand}>
<span className={styles.logo} aria-hidden="true">
<IconLogo />
<IconSearch />
</span>
<h1 className={styles.title}>QueryCraft</h1>
</div>
Expand Down
71 changes: 71 additions & 0 deletions src/components/ParamRow/ParamRow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,77 @@
min-width: 0;
}

/* ── Copy-on-hover ── */

.fieldWrap {
position: relative;
}

.copyBtn {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text-muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}

.fieldWrap:hover .copyBtn {
opacity: 1;
pointer-events: auto;
}

.copyBtn:hover {
color: var(--color-accent);
border-color: var(--color-border-strong);
}

.copyBtn.copied,
.copyBtnInline.copied {
color: var(--color-success);
}

/* Structured value: inline copy button (visible on row hover via structuredValue:hover) */
.copyBtnInline {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}

.structuredValue:hover .copyBtnInline {
opacity: 1;
pointer-events: auto;
}

.copyBtnInline:hover {
color: var(--color-accent);
border-color: var(--color-border-strong);
}

.removeCell {
display: flex;
justify-content: flex-end;
Expand Down
86 changes: 67 additions & 19 deletions src/components/ParamRow/ParamRow.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useId } from 'react';
import { useId, useState } from 'react';
import type { QueryParam } from '@/types';
import { parseStructuredValue, shortPreview } from '@/lib/structuredParam';
import { ParamKeyInput } from '../ParamKeyInput';
import { ParamValueInput } from '../ParamValueInput';
import { BooleanToggle } from '../BooleanToggle';
import { RemoveParamButton } from '../RemoveParamButton';
import { IconCheck, IconCopy } from '../icons';
import styles from './ParamRow.module.css';

interface ParamRowProps {
Expand All @@ -26,6 +27,15 @@ export function ParamRow({
}: ParamRowProps) {
const keyId = useId();
const valueId = useId();
const [copiedField, setCopiedField] = useState<'key' | 'value' | null>(null);

function handleCopy(text: string, field: 'key' | 'value') {
if (!text) return;
void navigator.clipboard.writeText(text).then(() => {
setCopiedField(field);
window.setTimeout(() => setCopiedField(null), 1500);
});
}

const isBool = param.type === 'boolean';
const isStructured = param.type === 'structured';
Expand All @@ -45,12 +55,22 @@ export function ParamRow({
<label htmlFor={keyId} className="visually-hidden">
Parameter key
</label>
<ParamKeyInput
id={keyId}
value={param.key}
aria-label={`Key for parameter ${param.key || '(empty)'}`}
onChange={(next) => onKeyChange(param.id, next)}
/>
<div className={styles.fieldWrap}>
<ParamKeyInput
id={keyId}
value={param.key}
aria-label={`Key for parameter ${param.key || '(empty)'}`}
onChange={(next) => onKeyChange(param.id, next)}
/>
<button
className={`${styles.copyBtn}${copiedField === 'key' ? ` ${styles.copied}` : ''}`}
onClick={() => handleCopy(param.key, 'key')}
aria-label={`Copy key "${param.key}"`}
tabIndex={-1}
>
{copiedField === 'key' ? <IconCheck /> : <IconCopy />}
</button>
</div>
</div>

<div className={styles.valueCell}>
Expand All @@ -59,12 +79,22 @@ export function ParamRow({
<label htmlFor={valueId} className="visually-hidden">
{`Boolean value for ${param.key}`}
</label>
<BooleanToggle
id={valueId}
value={param.value.toLowerCase() === 'true'}
aria-label={`Toggle boolean value for ${param.key}`}
onChange={() => onToggleBoolean(param.id)}
/>
<div className={styles.fieldWrap}>
<BooleanToggle
id={valueId}
value={param.value.toLowerCase() === 'true'}
aria-label={`Toggle boolean value for ${param.key}`}
onChange={() => onToggleBoolean(param.id)}
/>
<button
className={`${styles.copyBtn}${copiedField === 'value' ? ` ${styles.copied}` : ''}`}
onClick={() => handleCopy(param.value, 'value')}
aria-label={`Copy value "${param.value}"`}
tabIndex={-1}
>
{copiedField === 'value' ? <IconCheck /> : <IconCopy />}
</button>
</div>
</>
) : isStructured ? (
<div className={styles.structuredValue}>
Expand All @@ -85,18 +115,36 @@ export function ParamRow({
/>
</svg>
</button>
<button
className={`${styles.copyBtnInline}${copiedField === 'value' ? ` ${styles.copied}` : ''}`}
onClick={() => handleCopy(param.value, 'value')}
aria-label={`Copy structured value for ${param.key}`}
tabIndex={-1}
>
{copiedField === 'value' ? <IconCheck /> : <IconCopy />}
</button>
</div>
) : (
<>
<label htmlFor={valueId} className="visually-hidden">
{`Value for parameter ${param.key}`}
</label>
<ParamValueInput
id={valueId}
value={param.value}
aria-label={`Value for parameter ${param.key || '(empty key)'}`}
onChange={(next) => onValueChange(param.id, next)}
/>
<div className={styles.fieldWrap}>
<ParamValueInput
id={valueId}
value={param.value}
aria-label={`Value for parameter ${param.key || '(empty key)'}`}
onChange={(next) => onValueChange(param.id, next)}
/>
<button
className={`${styles.copyBtn}${copiedField === 'value' ? ` ${styles.copied}` : ''}`}
onClick={() => handleCopy(param.value, 'value')}
aria-label={`Copy value for parameter ${param.key || '(empty key)'}`}
tabIndex={-1}
>
{copiedField === 'value' ? <IconCheck /> : <IconCopy />}
</button>
</div>
</>
)}
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/components/SavedLinksDrawer/SavedLinksDrawer.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,22 @@
background: var(--color-surface);
}

.linkActions {
display: flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-fast);
}

.linkRow:hover .linkActions,
.linkRow:focus-within .linkActions {
opacity: 1;
pointer-events: auto;
}

.linkButton {
flex: 1;
min-width: 0;
Expand Down
Loading
Loading