From 8a7b20065145145d2fa6853dde5d617b110b08f8 Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Sat, 15 Aug 2026 11:36:07 +0200 Subject: [PATCH 01/22] Add filters --- .changeset/open-llamas-chew.md | 5 ++ AGENTS.md | 12 +++ src/components/app/app-header.tsx | 48 ++++++----- src/components/app/app.module.scss | 6 +- .../header-filters/header-filters.module.scss | 51 ++++++++++++ .../header-filters/header-filters.tsx | 83 +++++++++++++++++++ src/components/header-list/header-list.tsx | 19 +++-- src/components/text/text.module.scss | 3 + src/components/text/text.tsx | 11 ++- src/constants/index.ts | 2 + src/constants/scopes.ts | 15 ++++ src/contexts/headertweaker.context.tsx | 9 +- src/helpers/scope.helper.spec.ts | 50 ++++++++++- src/helpers/scope.helper.ts | 19 +++++ src/styles/global.scss | 2 +- 15 files changed, 303 insertions(+), 32 deletions(-) create mode 100644 .changeset/open-llamas-chew.md create mode 100644 src/components/header-filters/header-filters.module.scss create mode 100644 src/components/header-filters/header-filters.tsx create mode 100644 src/components/text/text.module.scss create mode 100644 src/constants/scopes.ts diff --git a/.changeset/open-llamas-chew.md b/.changeset/open-llamas-chew.md new file mode 100644 index 0000000..28749e9 --- /dev/null +++ b/.changeset/open-llamas-chew.md @@ -0,0 +1,5 @@ +--- +"headertweaker": minor +--- + +Add filters diff --git a/AGENTS.md b/AGENTS.md index 59e484f..6e43e1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,19 @@ pnpm format-n-lint:fix # Auto-fix lint and format issues `@components/*`, `@helpers/*`, `@contexts/*`, `@interfaces/*`, `@constants/*`, `@styles/*` - CSS: **SCSS modules** (`.module.scss`) co-located with each component - Components use named arrow-function exports typed as `FC`. +- Always render text through the `Text` component (`@components/text/text`); never place raw strings in bare DOM elements such as `` or `

`. - Declare all TypeScript types with `type`; do not use `interface`. Export a component prop type when it is shared. +- Constants belong in `src/constants/`, never inline in components. Define sets of options as a `SCREAMING_SNAKE_CASE` object with `as const` and derive the type from it: + + ```ts + export const SCOPES = { + ALL: 'all', + NO_SCOPE: 'no-scope', + } as const; + + export type Scope = (typeof SCOPES)[keyof typeof SCOPES]; + ``` + - Type DOM-wrapping components with `ComponentPropsWithoutRef<'element'>` and wrappers with `PropsWithChildren`. - Do not use `as any` or `as unknown as T` to silence TypeScript errors; narrow values to the required type instead. - Import `clsx` as `classnames`: `import classnames from 'clsx'`. Compose class names as `classnames(css.header, className)`. diff --git a/src/components/app/app-header.tsx b/src/components/app/app-header.tsx index 049db4e..97211ba 100644 --- a/src/components/app/app-header.tsx +++ b/src/components/app/app-header.tsx @@ -1,6 +1,7 @@ import { type FC, useState } from 'react'; import { IconButton } from '@components/button/icon-button'; import { Drawer } from '@components/drawer/drawer'; +import { HeaderFilters } from '@components/header-filters/header-filters'; import { Settings } from '@components/settings/settings'; import { Status } from '@components/status/status'; import { Text } from '@components/text/text'; @@ -20,28 +21,33 @@ export const AppHeader: FC = ({ withoutSettings = false }) => { return (

-
- - Header - Tweaker - - v{packageJson.version} - -
- {!withoutSettings && ( - <> - setShowSettings(true)}> - - +
+
+ + Header + Tweaker + + v{packageJson.version} + +
+ {!withoutSettings && ( + <> + setShowSettings(true)}> + + - setShowSettings(false)} title="Settings"> - - - - )} + setShowSettings(false)} title="Settings"> + + + + )} +
+
+ +
); }; diff --git a/src/components/app/app.module.scss b/src/components/app/app.module.scss index 3c7051b..98cfb8d 100644 --- a/src/components/app/app.module.scss +++ b/src/components/app/app.module.scss @@ -4,7 +4,7 @@ height: 100vh; } -.header { +.main { padding: 0 vars.$spacing-core-4; gap: vars.$spacing-core-2; display: flex; @@ -36,6 +36,10 @@ p { font-size: .8rem } } +.scopes { + padding: vars.$spacing-core-2 vars.$spacing-core-4; +} + .content { flex-grow: 1; overflow-y: auto; diff --git a/src/components/header-filters/header-filters.module.scss b/src/components/header-filters/header-filters.module.scss new file mode 100644 index 0000000..dc26c45 --- /dev/null +++ b/src/components/header-filters/header-filters.module.scss @@ -0,0 +1,51 @@ +.root { + position: relative; + display: inline-flex; + align-items: center; + padding: vars.$spacing-core-1; + border-radius: 999px; + background-color: rgb(255 255 255 / 4%); +} + +.indicator { + position: absolute; + top: vars.$spacing-core-1; + left: 0; + bottom: vars.$spacing-core-1; + border-radius: 999px; + background-color: vars.$colors-primary-action-disabled; + transition: transform 0.25s ease, width 0.25s ease; + pointer-events: none; +} + +.tab { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: vars.$spacing-core-2; + padding: vars.$spacing-core-2 vars.$spacing-core-4; + border: none; + border-radius: 999px; + background: none; + cursor: pointer; + white-space: nowrap; + font: inherit; + color: vars.$colors-tertiary-foreground; + transition: color 0.25s ease; + + &:hover { color: vars.$colors-primary-foreground } + + &.active { color: vars.$colors-primary-foreground } + + &:disabled { + cursor: not-allowed; + color: vars.$colors-tertiary-foreground; + } +} + +.disabled { + opacity: 0.5; + + .indicator { background-color: transparent } +} diff --git a/src/components/header-filters/header-filters.tsx b/src/components/header-filters/header-filters.tsx new file mode 100644 index 0000000..bd4f03e --- /dev/null +++ b/src/components/header-filters/header-filters.tsx @@ -0,0 +1,83 @@ +import { type FC, useEffect, useRef, useState } from 'react'; +import { Text } from '@components/text/text'; +import { SCOPE_LABELS, SCOPES, type Scope } from '@constants/index'; +import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; +import { getCurrentTabUrl } from '@helpers/header.helper'; +import { filterHeadersByScope } from '@helpers/scope.helper'; +import classnames from 'clsx'; + +import css from './header-filters.module.scss'; + +const scopes = Object.values(SCOPES); + +const getHost = (url?: string) => { + if (!url) return undefined; + + try { + return new URL(url).host.replace(/^www\./i, ''); + } catch { + return undefined; + } +}; + +export const HeaderFilters: FC = () => { + const { headers, showHeadersFilter, setShowHeadersFilter } = useHeaderTweakerContext(); + const [currentUrl, setCurrentUrl] = useState(undefined); + const [indicator, setIndicator] = useState({ left: 0, width: 0 }); + const listRef = useRef(null); + + const currentHost = getHost(currentUrl); + const isDisabled = !headers.length; + + useEffect(() => { + getCurrentTabUrl().then(setCurrentUrl); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure when the active tab or its label changes + useEffect(() => { + const active = listRef.current?.querySelector('[data-active="true"]'); + if (!active) return; + + setIndicator({ left: active.offsetLeft, width: active.offsetWidth }); + }, [showHeadersFilter, currentHost, headers]); + + return ( +
+ + {scopes.map((scope: Scope) => { + const isActive = showHeadersFilter === scope; + const count = filterHeadersByScope(headers, scope, currentUrl).length; + + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/header-list/header-list.tsx b/src/components/header-list/header-list.tsx index 298ab13..7fc0a24 100644 --- a/src/components/header-list/header-list.tsx +++ b/src/components/header-list/header-list.tsx @@ -6,6 +6,7 @@ import { Text } from '@components/text/text'; import { storage } from '@constants/index'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; import { getCurrentTabUrl } from '@helpers/header.helper'; +import { filterHeadersByScope } from '@helpers/scope.helper'; import classnames from 'clsx'; import css from './header-list.module.scss'; @@ -21,7 +22,10 @@ export const HeaderList: FC = () => { const [currentUrl, setCurrentUrl] = useState(undefined); const dragIndexRef = useRef(null); const tableRef = useRef(null); - const { headers, selectedHeader, reorderHeaders, useLabels } = useHeaderTweakerContext(); + const { headers, selectedHeader, reorderHeaders, useLabels, showHeadersFilter } = + useHeaderTweakerContext(); + + const visibleHeaders = filterHeadersByScope(headers, showHeadersFilter, currentUrl); useEffect(() => { storage.local.get(['nameColWidth', 'labelColWidth']).then((result) => { @@ -56,9 +60,14 @@ export const HeaderList: FC = () => { dragIndexRef.current = null; setDropIndex(null); if (from === null || from === index) return; + + const fromIndex = headers.findIndex(({ id }) => id === visibleHeaders[from]?.id); + const toIndex = headers.findIndex(({ id }) => id === visibleHeaders[index]?.id); + if (fromIndex === -1 || toIndex === -1) return; + const newHeaders = [...headers]; - const [moved] = newHeaders.splice(from, 1); - newHeaders.splice(index, 0, moved); + const [moved] = newHeaders.splice(fromIndex, 1); + newHeaders.splice(toIndex, 0, moved); await reorderHeaders(newHeaders); }; @@ -169,14 +178,14 @@ export const HeaderList: FC = () => { - {!headers.length ? ( + {!visibleHeaders.length ? ( No headers to display yet ) : ( - headers.map((header, index) => ( + visibleHeaders.map((header, index) => ( & { children: ReactNode; variant?: TextVariant; + textStyle?: 'primary' | 'secondary'; as?: HTMLElementType; }; export const Text: FC = ({ children, + textStyle = 'primary', variant = TextVariant.BODY, as: Tag = VariantTags[variant], ...props }: TextProps) => { - return {children}; + return ( + + {children} + + ); }; diff --git a/src/constants/index.ts b/src/constants/index.ts index 12f6f5d..f6af033 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -4,3 +4,5 @@ export const webRequest = isFirefox ? browser.webRequest : chrome.webRequest; export const tabs = isFirefox ? browser.tabs : chrome.tabs; export const IMPORT_PARAM = 'import'; + +export * from './scopes'; diff --git a/src/constants/scopes.ts b/src/constants/scopes.ts new file mode 100644 index 0000000..4d43cfd --- /dev/null +++ b/src/constants/scopes.ts @@ -0,0 +1,15 @@ +export const SCOPES = { + ALL: 'all', + SCOPED: 'scoped', + NO_SCOPE: 'no-scope', + CURRENT_URL: 'current-url', +} as const; + +export type Scope = (typeof SCOPES)[keyof typeof SCOPES]; + +export const SCOPE_LABELS: Record = { + [SCOPES.ALL]: 'All', + [SCOPES.SCOPED]: 'URL-specific', + [SCOPES.NO_SCOPE]: 'Global', + [SCOPES.CURRENT_URL]: 'Current', +}; diff --git a/src/contexts/headertweaker.context.tsx b/src/contexts/headertweaker.context.tsx index b61df7e..394e640 100644 --- a/src/contexts/headertweaker.context.tsx +++ b/src/contexts/headertweaker.context.tsx @@ -7,7 +7,7 @@ import { useEffect, useState, } from 'react'; -import { storage } from '@constants/index'; +import { SCOPES, type Scope, storage } from '@constants/index'; import { activateHeader, addHeader, @@ -35,11 +35,13 @@ type HeaderTweakerContextValue = { isDisabled: boolean; useLabels: boolean; selectedHeader: Header | null; + showHeadersFilter: Scope; updateHeader: (args: HeaderFn) => Promise; importHeaders: (headers: Header[]) => Promise; reorderHeaders: (headers: Header[]) => Promise; setStatus: (status: Status) => Promise; setUseLabels: (show: boolean) => void; + setShowHeadersFilter: Dispatch>; setSelectedHeader: Dispatch>; }; @@ -49,11 +51,13 @@ const initialState: HeaderTweakerContextValue = { loading: false, isDisabled: false, useLabels: false, + showHeadersFilter: SCOPES.ALL, updateHeader: async () => {}, importHeaders: async () => {}, reorderHeaders: async () => {}, setSelectedHeader: () => {}, setUseLabels: () => {}, + setShowHeadersFilter: () => {}, setStatus: async () => {}, }; @@ -75,6 +79,7 @@ export const HeaderTweakerProvider = ({ children }: HeaderTweakerContextProps) = const [useLabels, setUseLabels] = useState(false); const [headerList, setHeaderList] = useState([]); const [selectedHeader, setSelectedHeaderRaw] = useState
(null); + const [showHeadersFilter, setShowHeadersFilter] = useState(SCOPES.ALL); const setSelectedHeader = (value: SetStateAction
) => { setSelectedHeaderRaw(value); @@ -152,6 +157,8 @@ export const HeaderTweakerProvider = ({ children }: HeaderTweakerContextProps) = isDisabled, useLabels, selectedHeader, + showHeadersFilter, + setShowHeadersFilter, setSelectedHeader, setStatus, headers: headerList, diff --git a/src/helpers/scope.helper.spec.ts b/src/helpers/scope.helper.spec.ts index 9eda59d..f21175b 100644 --- a/src/helpers/scope.helper.spec.ts +++ b/src/helpers/scope.helper.spec.ts @@ -1,6 +1,18 @@ +import { SCOPES } from '@constants/scopes'; import type { Header } from '@interfaces/index'; -import { describe, expect, it } from 'vitest'; -import { isDuplicateUrl, normalizeUrlRestriction } from './scope.helper'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@constants/index', () => ({ + storage: { local: { get: vi.fn(), set: vi.fn() } }, + tabs: { query: vi.fn() }, +})); + +import { + filterHeadersByScope, + isDuplicateUrl, + isScoped, + normalizeUrlRestriction, +} from './scope.helper'; const header = (urls?: string[]): Header => ({ id: 'header-id', @@ -49,3 +61,37 @@ describe('isDuplicateUrl', () => { expect(isDuplicateUrl(header(['example.com', 'api.example.com']), 1)).toBe(false); }); }); + +describe('isScoped', () => { + it('is only true when the header has at least one URL', () => { + expect(isScoped(header())).toBe(false); + expect(isScoped(header([]))).toBe(false); + expect(isScoped(header(['example.com']))).toBe(true); + }); +}); + +describe('filterHeadersByScope', () => { + const unscoped = { ...header(), id: 'unscoped' }; + const scoped = { ...header(['https://example.com/*']), id: 'scoped' }; + const other = { ...header(['https://other.com/*']), id: 'other' }; + const headers = [unscoped, scoped, other]; + + it('returns every header for the "all" scope', () => { + expect(filterHeadersByScope(headers, SCOPES.ALL)).toEqual(headers); + }); + + it('returns only headers with or without URLs', () => { + expect(filterHeadersByScope(headers, SCOPES.SCOPED)).toEqual([scoped, other]); + expect(filterHeadersByScope(headers, SCOPES.NO_SCOPE)).toEqual([unscoped]); + }); + + it('returns only headers matching the current URL', () => { + expect(filterHeadersByScope(headers, SCOPES.CURRENT_URL, 'https://example.com/page')).toEqual([ + scoped, + ]); + }); + + it('returns nothing for the current URL scope without a URL', () => { + expect(filterHeadersByScope(headers, SCOPES.CURRENT_URL)).toEqual([]); + }); +}); diff --git a/src/helpers/scope.helper.ts b/src/helpers/scope.helper.ts index e722afe..4fc572e 100644 --- a/src/helpers/scope.helper.ts +++ b/src/helpers/scope.helper.ts @@ -1,3 +1,5 @@ +import { SCOPES, type Scope } from '@constants/scopes'; +import { matchesUrl } from '@helpers/header.helper'; import type { Header } from '@interfaces/index'; export const normalizeUrlRestriction = (url: string) => { @@ -27,3 +29,20 @@ export const isDuplicateUrl = (header: Header | null, index: number) => { }) ); }; + +export const isScoped = (header: Header) => Boolean(header.urls?.length); + +export const filterHeadersByScope = (headers: Header[], scope: Scope, currentUrl?: string) => { + switch (scope) { + case SCOPES.SCOPED: + return headers.filter(isScoped); + case SCOPES.NO_SCOPE: + return headers.filter((header) => !isScoped(header)); + case SCOPES.CURRENT_URL: + return currentUrl + ? headers.filter((header) => isScoped(header) && matchesUrl(currentUrl, header.urls ?? [])) + : []; + default: + return headers; + } +}; diff --git a/src/styles/global.scss b/src/styles/global.scss index 9527f78..8ff4de8 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -4,7 +4,7 @@ body { background-color: vars.$colors-primary-background; width: 800px; - height: 500px; + height: 560px; padding:0; } From ef18922902736d1cfa9a910891ae23b55930f55e Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Sat, 15 Aug 2026 12:07:23 +0200 Subject: [PATCH 02/22] Improve empty state messages --- .changeset/open-llamas-chew.md | 5 +- src/components/header-list/header-list.tsx | 49 ++++++++++--------- src/components/placeholders/no-headers.tsx | 12 +++++ .../placeholders/placeholders.module.scss | 7 +++ src/helpers/scope.helper.spec.ts | 21 ++++++++ src/helpers/scope.helper.ts | 13 +++++ 6 files changed, 82 insertions(+), 25 deletions(-) create mode 100644 src/components/placeholders/no-headers.tsx create mode 100644 src/components/placeholders/placeholders.module.scss diff --git a/.changeset/open-llamas-chew.md b/.changeset/open-llamas-chew.md index 28749e9..1a9a966 100644 --- a/.changeset/open-llamas-chew.md +++ b/.changeset/open-llamas-chew.md @@ -2,4 +2,7 @@ "headertweaker": minor --- -Add filters +changes: + +- Add filter bar to distinguish headers with a URL scope +- Improve empty header-list states diff --git a/src/components/header-list/header-list.tsx b/src/components/header-list/header-list.tsx index 7fc0a24..8bb7736 100644 --- a/src/components/header-list/header-list.tsx +++ b/src/components/header-list/header-list.tsx @@ -2,11 +2,12 @@ import { type FC, useEffect, useRef, useState } from 'react'; import { Drawer } from '@components/drawer/drawer'; import { EditHeader } from '@components/edit-header/edit-header'; import { HeaderItem } from '@components/header-list/header-item'; +import { NoHeaders } from '@components/placeholders/no-headers'; import { Text } from '@components/text/text'; import { storage } from '@constants/index'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; import { getCurrentTabUrl } from '@helpers/header.helper'; -import { filterHeadersByScope } from '@helpers/scope.helper'; +import { filterHeadersByScope, getScopeErrorMessage } from '@helpers/scope.helper'; import classnames from 'clsx'; import css from './header-list.module.scss'; @@ -130,6 +131,14 @@ export const HeaderList: FC = () => { document.addEventListener('mouseup', handleMouseUp); }; + if (!headers.length) { + return ; + } + + if (!visibleHeaders.length) { + return ; + } + return (
@@ -178,29 +187,21 @@ export const HeaderList: FC = () => { - {!visibleHeaders.length ? ( - - - - ) : ( - visibleHeaders.map((header, index) => ( - - )) - )} + {visibleHeaders.map((header, index) => ( + + ))}
- No headers to display yet -
setOpen(false)}> diff --git a/src/components/placeholders/no-headers.tsx b/src/components/placeholders/no-headers.tsx new file mode 100644 index 0000000..0f415e5 --- /dev/null +++ b/src/components/placeholders/no-headers.tsx @@ -0,0 +1,12 @@ +import type { FC } from 'react'; +import { Text } from '@components/text/text'; + +import css from './placeholders.module.scss'; + +export const NoHeaders: FC<{ message: string }> = ({ message }) => { + return ( +
+ {message} +
+ ); +}; diff --git a/src/components/placeholders/placeholders.module.scss b/src/components/placeholders/placeholders.module.scss new file mode 100644 index 0000000..87d2fa1 --- /dev/null +++ b/src/components/placeholders/placeholders.module.scss @@ -0,0 +1,7 @@ +.page { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/src/helpers/scope.helper.spec.ts b/src/helpers/scope.helper.spec.ts index f21175b..c88ea1c 100644 --- a/src/helpers/scope.helper.spec.ts +++ b/src/helpers/scope.helper.spec.ts @@ -9,6 +9,7 @@ vi.mock('@constants/index', () => ({ import { filterHeadersByScope, + getScopeErrorMessage, isDuplicateUrl, isScoped, normalizeUrlRestriction, @@ -95,3 +96,23 @@ describe('filterHeadersByScope', () => { expect(filterHeadersByScope(headers, SCOPES.CURRENT_URL)).toEqual([]); }); }); + +describe('getScopeErrorMessage', () => { + it('returns a distinct message for every scope', () => { + const messages = Object.values(SCOPES).map(getScopeErrorMessage); + + expect(new Set(messages).size).toBe(messages.length); + expect(messages.every(Boolean)).toBe(true); + }); + + it('explains why the selected scope is empty', () => { + expect(getScopeErrorMessage(SCOPES.ALL)).toBe('No headers match the selected filter'); + expect(getScopeErrorMessage(SCOPES.SCOPED)).toBe('None of the headers are limited to a URL'); + expect(getScopeErrorMessage(SCOPES.NO_SCOPE)).toBe( + 'Every header is limited to a URL, so none apply everywhere' + ); + expect(getScopeErrorMessage(SCOPES.CURRENT_URL)).toBe( + 'None of the headers apply to the current URL' + ); + }); +}); diff --git a/src/helpers/scope.helper.ts b/src/helpers/scope.helper.ts index 4fc572e..0d8f0de 100644 --- a/src/helpers/scope.helper.ts +++ b/src/helpers/scope.helper.ts @@ -46,3 +46,16 @@ export const filterHeadersByScope = (headers: Header[], scope: Scope, currentUrl return headers; } }; + +export const getScopeErrorMessage = (scope: Scope) => { + switch (scope) { + case SCOPES.SCOPED: + return 'None of the headers are limited to a URL'; + case SCOPES.NO_SCOPE: + return 'Every header is limited to a URL, so none apply everywhere'; + case SCOPES.CURRENT_URL: + return 'None of the headers apply to the current URL'; + default: + return 'No headers match the selected filter'; + } +}; From 4d9247eed12c49263097cea760959810e98e9427 Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Sat, 15 Aug 2026 12:16:29 +0200 Subject: [PATCH 03/22] Focus header key after creating a header --- .changeset/open-llamas-chew.md | 1 + src/components/app/app-footer.tsx | 5 +++- src/components/input/input.tsx | 40 +++++++++++++++---------------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/.changeset/open-llamas-chew.md b/.changeset/open-llamas-chew.md index 1a9a966..e68fe35 100644 --- a/.changeset/open-llamas-chew.md +++ b/.changeset/open-llamas-chew.md @@ -6,3 +6,4 @@ changes: - Add filter bar to distinguish headers with a URL scope - Improve empty header-list states +- Place focus on header key input field after creating a header diff --git a/src/components/app/app-footer.tsx b/src/components/app/app-footer.tsx index 02e984d..b11cb54 100644 --- a/src/components/app/app-footer.tsx +++ b/src/components/app/app-footer.tsx @@ -1,4 +1,4 @@ -import { type ChangeEvent, type FC, type KeyboardEvent, useEffect, useState } from 'react'; +import { type ChangeEvent, type FC, type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { Button } from '@components/button/button'; import { Input } from '@components/input/input'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; @@ -13,6 +13,7 @@ type AppFooterProps = Record; export const AppFooter: FC = () => { const [header, setHeader] = useState
(); const [disabledButton, setDisabledButton] = useState(true); + const headerKeyRef = useRef(null); const { isDisabled, updateHeader } = useHeaderTweakerContext(); const handleInputChange = (e: ChangeEvent) => { @@ -48,6 +49,7 @@ export const AppFooter: FC = () => { if (header) { await updateHeader({ header, action: 'add' }); setHeader(undefined); + headerKeyRef.current?.focus(); } }; @@ -62,6 +64,7 @@ export const AppFooter: FC = () => {
> = ({ - type, - placeholder, - 'aria-label': ariaLabel, - ...props -}: ComponentPropsWithoutRef<'input'>) => { - const id = useId(); +export const Input = forwardRef>( + ({ type, placeholder, 'aria-label': ariaLabel, ...props }, ref) => { + const id = useId(); - return ( - - ); -}; + return ( + + ); + } +); + +Input.displayName = 'Input'; From 9a49e9dff4797590129936d96e735bfa9fbad07d Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Sat, 15 Aug 2026 12:35:16 +0200 Subject: [PATCH 04/22] Add audit to pr workflow --- .github/workflows/pr-checks.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d85d8a4..a1eb1fb 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -33,19 +33,20 @@ jobs: echo "$CHANGES" fi - - name: Use Node.js + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" - - - name: Install pnpm - run: | - corepack enable - corepack prepare pnpm@latest --activate - pnpm --version + node-version-file: .nvmrc + cache: pnpm - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Audit + run: pnpm audit --audit-level=high --prod - name: Lint run: pnpm lint From 8b6a8cd1b4379ee429a84d5cfd7ea94d6ea22bef Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Sat, 15 Aug 2026 13:57:53 +0200 Subject: [PATCH 05/22] Add dependabot config --- .github/dependabot.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..88df955 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + react: + patterns: + - react + - react-dom + - "@types/react" + - "@types/react-dom" + dev-dependencies: + dependency-type: development + update-types: + - minor + - patch + production-dependencies: + dependency-type: production + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" From 7e790c63ee2ef150cbdf4c429345dd5129042be0 Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Sat, 15 Aug 2026 14:30:27 +0200 Subject: [PATCH 06/22] Group headers by scope --- .changeset/open-llamas-chew.md | 1 + .github/workflows/pr-checks.yml | 2 +- package.json | 2 +- src/components/header-list/header-item.tsx | 54 +++++++------ .../header-list/header-list.module.scss | 7 ++ src/components/header-list/header-list.tsx | 75 ++++++++++++++----- src/helpers/scope.helper.spec.ts | 44 +++++++++++ src/helpers/scope.helper.ts | 24 ++++++ 8 files changed, 164 insertions(+), 45 deletions(-) diff --git a/.changeset/open-llamas-chew.md b/.changeset/open-llamas-chew.md index e68fe35..c24e979 100644 --- a/.changeset/open-llamas-chew.md +++ b/.changeset/open-llamas-chew.md @@ -7,3 +7,4 @@ changes: - Add filter bar to distinguish headers with a URL scope - Improve empty header-list states - Place focus on header key input field after creating a header +- Group headers on the URL-specific tab by their exact scoped url diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index a1eb1fb..f63120e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -19,7 +19,7 @@ jobs: git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} - name: Check for changeset - if: github.actor != 'github-actions[bot]' + if: github.actor != 'github-actions[bot]' && github.actor != 'dependabot[bot]' shell: bash run: | CHANGES=$(git diff --name-status origin/${{ github.base_ref }}...HEAD \ diff --git a/package.json b/package.json index df05a55..b642f85 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "packageManager": "pnpm@10.17.1", "engines": { - "node": ">=20", + "node": ">=24", "pnpm": ">=10" }, "dependencies": { diff --git a/src/components/header-list/header-item.tsx b/src/components/header-list/header-item.tsx index cf164d3..32f5c82 100644 --- a/src/components/header-list/header-item.tsx +++ b/src/components/header-list/header-item.tsx @@ -5,6 +5,7 @@ import { HeaderContent } from '@components/header-content/header-content'; import { Switch } from '@components/switch/switch'; import { Text } from '@components/text/text'; import { Tooltip, TooltipContent, TooltipTrigger } from '@components/tooltip/tooltip'; +import { SCOPES } from '@constants/index'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; import { matchesUrl } from '@helpers/header.helper'; import { Bars3Icon, GlobeAltIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/solid'; @@ -43,7 +44,8 @@ export const HeaderItem: FC = ({ onDragEnd, }: HeaderItemProps) => { const [headerToDelete, setHeaderToDelete] = useState
(null); - const { isDisabled, setSelectedHeader, updateHeader } = useHeaderTweakerContext(); + const { isDisabled, setSelectedHeader, updateHeader, showHeadersFilter } = + useHeaderTweakerContext(); const isScoped = urls && urls.length >= 1; const isCurrentUrl = !!(isScoped && currentUrl && matchesUrl(currentUrl, urls)); @@ -88,31 +90,35 @@ export const HeaderItem: FC = ({ - - - - + {showHeadersFilter === SCOPES.ALL ? ( + + + + - - {!isScoped && This header is not scoped to a specific url} + + {!isScoped && The header is not limited to a specific URL} - {isScoped ? ( - isCurrentUrl ? ( - This header is scoped to the current url - ) : ( - The header is scoped to the following url's: {urls?.join(', ')} - ) - ) : null} - - + {isScoped ? ( + isCurrentUrl ? ( + The header will applied to the current URL + ) : ( + + The header will be applied to the following URL's: {urls?.join(', ')} + + ) + ) : null} + + + ) : null} diff --git a/src/components/header-list/header-list.module.scss b/src/components/header-list/header-list.module.scss index eb45490..c1aeb5c 100644 --- a/src/components/header-list/header-list.module.scss +++ b/src/components/header-list/header-list.module.scss @@ -149,4 +149,11 @@ vertical-align: middle; .scopedIcon { display: block; } +} + +.groupRow { + td { + padding-top: vars.$spacing-core-4; + padding-bottom: vars.$spacing-core-2; + } } \ No newline at end of file diff --git a/src/components/header-list/header-list.tsx b/src/components/header-list/header-list.tsx index 8bb7736..c68fe62 100644 --- a/src/components/header-list/header-list.tsx +++ b/src/components/header-list/header-list.tsx @@ -4,10 +4,14 @@ import { EditHeader } from '@components/edit-header/edit-header'; import { HeaderItem } from '@components/header-list/header-item'; import { NoHeaders } from '@components/placeholders/no-headers'; import { Text } from '@components/text/text'; -import { storage } from '@constants/index'; +import { SCOPES, storage } from '@constants/index'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; import { getCurrentTabUrl } from '@helpers/header.helper'; -import { filterHeadersByScope, getScopeErrorMessage } from '@helpers/scope.helper'; +import { + filterHeadersByScope, + getScopeErrorMessage, + groupHeadersByUrl, +} from '@helpers/scope.helper'; import classnames from 'clsx'; import css from './header-list.module.scss'; @@ -186,23 +190,56 @@ export const HeaderList: FC = () => { - - {visibleHeaders.map((header, index) => ( - - ))} - + {showHeadersFilter === SCOPES.SCOPED ? ( + groupHeadersByUrl(visibleHeaders).map((group) => ( + + + + + {group.url} + + + + {group.headers.map((header) => { + const index = visibleHeaders.findIndex(({ id }) => id === header.id); + + return ( + + ); + })} + + )) + ) : ( + + {visibleHeaders.map((header, index) => ( + + ))} + + )} setOpen(false)}> {selectedHeader && setOpen(false)} />} diff --git a/src/helpers/scope.helper.spec.ts b/src/helpers/scope.helper.spec.ts index c88ea1c..0391a4f 100644 --- a/src/helpers/scope.helper.spec.ts +++ b/src/helpers/scope.helper.spec.ts @@ -10,6 +10,7 @@ vi.mock('@constants/index', () => ({ import { filterHeadersByScope, getScopeErrorMessage, + groupHeadersByUrl, isDuplicateUrl, isScoped, normalizeUrlRestriction, @@ -97,6 +98,49 @@ describe('filterHeadersByScope', () => { }); }); +describe('groupHeadersByUrl', () => { + it('groups headers by each exact scoped url', () => { + const a = { ...header(['example.com/a']), id: 'a' }; + const b = { ...header(['example.com/b']), id: 'b' }; + const c = { ...header(['example.com/a']), id: 'c' }; + + expect(groupHeadersByUrl([a, b, c])).toEqual([ + { url: 'example.com/a', headers: [a, c] }, + { url: 'example.com/b', headers: [b] }, + ]); + }); + + it('lists a header under every url it is scoped to', () => { + const multi = { ...header(['example.com/a', 'example.com/b']), id: 'multi' }; + + expect(groupHeadersByUrl([multi])).toEqual([ + { url: 'example.com/a', headers: [multi] }, + { url: 'example.com/b', headers: [multi] }, + ]); + }); + + it('sorts groups alphabetically by url', () => { + const b = { ...header(['example.com/b']), id: 'b' }; + const a = { ...header(['example.com/a']), id: 'a' }; + + expect(groupHeadersByUrl([b, a]).map((group) => group.url)).toEqual([ + 'example.com/a', + 'example.com/b', + ]); + }); + + it('treats different paths on the same host as distinct groups', () => { + const a = { ...header(['example.com/pathA']), id: 'a' }; + const b = { ...header(['example.com/pathB']), id: 'b' }; + + expect(groupHeadersByUrl([a, b])).toHaveLength(2); + }); + + it('returns no groups for unscoped headers', () => { + expect(groupHeadersByUrl([header()])).toEqual([]); + }); +}); + describe('getScopeErrorMessage', () => { it('returns a distinct message for every scope', () => { const messages = Object.values(SCOPES).map(getScopeErrorMessage); diff --git a/src/helpers/scope.helper.ts b/src/helpers/scope.helper.ts index 0d8f0de..81a451e 100644 --- a/src/helpers/scope.helper.ts +++ b/src/helpers/scope.helper.ts @@ -47,6 +47,30 @@ export const filterHeadersByScope = (headers: Header[], scope: Scope, currentUrl } }; +export type HeaderGroup = { + url: string; + headers: Header[]; +}; + +export const groupHeadersByUrl = (headers: Header[]): HeaderGroup[] => { + const groups = new Map(); + + for (const header of headers) { + for (const url of header.urls ?? []) { + const group = groups.get(url); + if (group) { + group.push(header); + } else { + groups.set(url, [header]); + } + } + } + + return Array.from(groups.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([url, groupedHeaders]) => ({ url, headers: groupedHeaders })); +}; + export const getScopeErrorMessage = (scope: Scope) => { switch (scope) { case SCOPES.SCOPED: From 1634790519e0befc1807af84fa43f855f79bc5dd Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Tue, 18 Aug 2026 12:34:57 +0200 Subject: [PATCH 07/22] Bulk change header scope groundwork --- .changeset/open-llamas-chew.md | 1 + AGENTS.md | 33 ++- src/components/app/app-footer.tsx | 8 +- src/components/app/app-header.tsx | 74 ++++--- src/components/app/app.module.scss | 5 + .../bulk-scope-change/bulk-scope-change.tsx | 37 ++++ .../elements/save-button.tsx | 26 +++ .../elements/select-headers.tsx | 58 ++++++ .../elements/select-urls.tsx | 25 +++ .../edit-header/edit-header.module.scss | 22 -- src/components/edit-header/edit-header.tsx | 102 ++------- .../header-filters/header-filters.tsx | 8 +- src/components/header-list/header-item.tsx | 2 +- src/components/header-list/header-list.tsx | 3 +- src/components/input/checkbox.tsx | 58 ++++++ src/components/input/input.module.scss | 70 ++++++- src/components/input/input.tsx | 24 --- src/components/input/text-input.tsx | 27 +++ src/components/modal/modal.module.scss | 2 + src/components/modal/modal.tsx | 11 +- src/components/select/select.module.scss | 109 ++++++++++ src/components/select/select.tsx | 196 ++++++++++++++++++ src/components/steps/elements/final-step.tsx | 27 +++ .../steps/elements/step-indicators.tsx | 44 ++++ .../steps/elements/step-navigation.tsx | 49 +++++ src/components/steps/elements/step.tsx | 25 +++ src/components/steps/steps.module.scss | 145 +++++++++++++ src/components/steps/steps.tsx | 54 +++++ .../url-selector/url-selector.module.scss | 21 ++ src/components/url-selector/url-selector.tsx | 97 +++++++++ src/constants/index.ts | 2 - src/constants/select.ts | 6 + src/contexts/bulk-scope-change.context.tsx | 49 +++++ src/contexts/headertweaker.context.tsx | 5 +- src/contexts/steps.context.tsx | 28 +++ src/helpers/scope.helper.spec.ts | 26 +++ src/helpers/scope.helper.ts | 41 +++- src/styles/reset.scss | 2 + 38 files changed, 1331 insertions(+), 191 deletions(-) create mode 100644 src/components/bulk-scope-change/bulk-scope-change.tsx create mode 100644 src/components/bulk-scope-change/elements/save-button.tsx create mode 100644 src/components/bulk-scope-change/elements/select-headers.tsx create mode 100644 src/components/bulk-scope-change/elements/select-urls.tsx create mode 100644 src/components/input/checkbox.tsx delete mode 100644 src/components/input/input.tsx create mode 100644 src/components/input/text-input.tsx create mode 100644 src/components/select/select.module.scss create mode 100644 src/components/select/select.tsx create mode 100644 src/components/steps/elements/final-step.tsx create mode 100644 src/components/steps/elements/step-indicators.tsx create mode 100644 src/components/steps/elements/step-navigation.tsx create mode 100644 src/components/steps/elements/step.tsx create mode 100644 src/components/steps/steps.module.scss create mode 100644 src/components/steps/steps.tsx create mode 100644 src/components/url-selector/url-selector.module.scss create mode 100644 src/components/url-selector/url-selector.tsx create mode 100644 src/constants/select.ts create mode 100644 src/contexts/bulk-scope-change.context.tsx create mode 100644 src/contexts/steps.context.tsx diff --git a/.changeset/open-llamas-chew.md b/.changeset/open-llamas-chew.md index c24e979..2be9bf6 100644 --- a/.changeset/open-llamas-chew.md +++ b/.changeset/open-llamas-chew.md @@ -8,3 +8,4 @@ changes: - Improve empty header-list states - Place focus on header key input field after creating a header - Group headers on the URL-specific tab by their exact scoped url +- Add a Select component and reuse previously used URLs when scoping a header diff --git a/AGENTS.md b/AGENTS.md index 6e43e1f..e58ca05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ pnpm format-n-lint:fix # Auto-fix lint and format issues - Use **path aliases** for cross-directory imports (never relative `../../`): `@components/*`, `@helpers/*`, `@contexts/*`, `@interfaces/*`, `@constants/*`, `@styles/*` -- CSS: **SCSS modules** (`.module.scss`) co-located with each component +- **SCSS styling**: Use SCSS modules (`.module.scss`) co-located with each component. Import design tokens using `@use '@styles/variables' as vars;` and reference tokens via `vars.$colors-*`, `vars.$spacing-core-*`, `vars.$border-radius-primary`, etc. Never hardcode colors or spacing values — always use design tokens from `src/styles/variables.scss`. - Components use named arrow-function exports typed as `FC`. - Always render text through the `Text` component (`@components/text/text`); never place raw strings in bare DOM elements such as `` or `

`. - Declare all TypeScript types with `type`; do not use `interface`. Export a component prop type when it is shared. @@ -55,7 +55,36 @@ pnpm format-n-lint:fix # Auto-fix lint and format issues - Type DOM-wrapping components with `ComponentPropsWithoutRef<'element'>` and wrappers with `PropsWithChildren`. - Do not use `as any` or `as unknown as T` to silence TypeScript errors; narrow values to the required type instead. - Import `clsx` as `classnames`: `import classnames from 'clsx'`. Compose class names as `classnames(css.header, className)`. -- Keep compound-component subcomponents in the parent component's `elements/` folder. The root component module must re-export each element directly; do not add an `elements/index.ts` barrel. For example, `modal.tsx` should contain `export { ModalHeader } from './elements/modal-header';`. +- **Composition over configuration**: pass content as `children` rather than through content props (`label`, `icon`, `text`). `Button`, `IconButton`, and `Text` follow this. +- **Compound components**: Build multi-part components using composition, not props. Keep subcomponents in an `elements/` folder and re-export them from the parent module (no `elements/index.ts` barrel). The parent manages state and shares it with its subcomponents through a context. This pattern is used for `Modal` and `Steps`. + +```tsx +// src/components/steps/steps.tsx + + + Content + + +``` + +- **Contexts**: Create all contexts in `src/contexts/` with the naming convention `*.context.tsx`. A context file exports the context value type, the context, a dedicated provider component, and a `use*Context` consumer hook that throws when used outside its provider. Always consume a context through its hook — never `useContext` directly. + +```tsx +// src/contexts/steps.context.tsx +export type StepsContextValue = { /* ... */ }; +export const StepsContext = createContext(undefined); +export const StepsProvider: FC> = ({ value, children }) => ( + {children} +); +export const useStepsContext = (): StepsContextValue => { + const context = useContext(StepsContext); + + if (!context) throw new Error('useStepsContext must be used within a StepsProvider'); + + return context; +}; +``` + - Linting/formatting: **Biome** for JS/TS/JSON, **Stylelint** for SCSS — both run in CI ## Testing diff --git a/src/components/app/app-footer.tsx b/src/components/app/app-footer.tsx index b11cb54..14ed2cd 100644 --- a/src/components/app/app-footer.tsx +++ b/src/components/app/app-footer.tsx @@ -1,6 +1,6 @@ import { type ChangeEvent, type FC, type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { Button } from '@components/button/button'; -import { Input } from '@components/input/input'; +import { TextInput } from '@components/input/text-input'; import { useHeaderTweakerContext } from '@contexts/headertweaker.context'; import { cleanupHeaderKey } from '@helpers/validation.helper'; import { PlusCircleIcon } from '@heroicons/react/24/solid'; @@ -62,8 +62,7 @@ export const AppFooter: FC = () => { return (