Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
15ec97d
feat: export buildReport() to write the static report from a script
erkamyaman Nov 7, 2026
af3c9e8
feat(ui): one shared Filter search field with saved filter chips
erkamyaman Nov 7, 2026
7940636
feat(ui): resizable list and detail panes with a keyboard splitter
erkamyaman Nov 7, 2026
cc65601
feat(popup): opt-in counts launcher with render and error counts
erkamyaman Nov 7, 2026
21f76f8
feat(extension): find apps inside frames and add an Elements sidebar …
erkamyaman Nov 7, 2026
2b8c0f0
feat(extension): add a toolbar popup and a site access page
erkamyaman Nov 7, 2026
a8ca87d
docs: cover the Filter field, saved filters, splitters, the counts la…
erkamyaman Nov 7, 2026
322f09d
fix(ui): import the splitter on the injectors and store pages
erkamyaman Nov 7, 2026
be7f10c
test(ui): check the page sources in the node environment
erkamyaman Nov 7, 2026
b58aa1a
fix(ui): give the splitter width a default
erkamyaman Nov 7, 2026
a3a8518
build(extension): rebuild the panel UI with the Filter field, splitte…
erkamyaman Nov 7, 2026
a9bfd1e
Merge remote-tracking branch 'origin/main' into feat/panel-shell-exte…
erkamyaman Nov 7, 2026
11d318c
fix(popup): keep the bring-back label while the panel is popped out
erkamyaman Nov 7, 2026
c8e261b
fix(overlay): hand the launcher counts to a popup that loads late
erkamyaman Nov 7, 2026
79e527e
docs(extension): nest the toolbar button section and name the search …
erkamyaman Nov 7, 2026
93918fb
Merge remote-tracking branch 'origin/main' into feat/panel-shell-exte…
erkamyaman Nov 7, 2026
48a8b33
Merge remote-tracking branch 'origin/main' into feat/panel-shell-exte…
erkamyaman Nov 7, 2026
9d1b2e4
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
d7bac9a
Merge remote-tracking branch 'origin/main' into feat/panel-shell-exte…
erkamyaman Nov 7, 2026
87904cb
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
ea28db9
fix(popup): restore the pill label after a pop-out and re-place the p…
erkamyaman Nov 7, 2026
4301e39
fix(ui): send the stored counts pill to the popup when the panel conn…
erkamyaman Nov 7, 2026
1586d06
docs(extension): correct the pane fields, the Filter placeholder rule…
erkamyaman Nov 7, 2026
84dd801
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
2e9cdbe
fix: count only a real .. segment as leaving the report outDir
erkamyaman Nov 7, 2026
10c325e
docs: say how buildReport() resolves outDir and what config takes
erkamyaman Nov 7, 2026
c9f19f8
Merge remote-tracking branch 'origin/main' into feat/panel-shell-exte…
erkamyaman Nov 7, 2026
9be2984
Merge remote-tracking branch 'origin/main' into feat/panel-shell-exte…
erkamyaman Nov 7, 2026
94d8a28
build(extension): rebuild extension/ui after the main merges
erkamyaman Nov 7, 2026
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 .claude/skills/devtools-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Read `docs/contributing/ui-guidelines.md` first; it is the source of truth for t
- Styles are SCSS in the component `styles` field, starting with `@use 'mixins' as m;`.
- Colors only through CSS variables (`--surface`, `--text-2`, `--accent`, ...). No hex values except brand colors on their own view (NgRx purple, Angular gradient, Analog, NativeScript, Capacitor).
- Controls are `var(--control-h)` tall. Use `app/src/ui/select.ts` for every dropdown; never a native `<select>`.
- Page structure: intro line, sticky toolbar (search with icon and `Escape` to clear, filters, count, actions), content (list or tree plus sticky detail on wide screens), and loading, error with Retry, empty and no-match states.
- Page structure: intro line, sticky toolbar (`app/src/ui/search-field.ts` named "Filter", filters, count, actions), content (list or tree plus sticky detail on wide screens), and loading, error with Retry, empty and no-match states.
- Headings start at `h2` and never skip a level. Section labels use `m.label`.
- Rows are keyboard reachable (buttons or ARIA tree items with arrow keys). Selection is keyed by stable ids from the page.
- Rows that map to an element in the app call `request-page-highlight` on hover and focus, and clear it on leave and blur.
Expand Down
35 changes: 35 additions & 0 deletions app/src/__tests__/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,38 @@ describe('App tab bar', () => {
fixture.destroy();
});
});

describe('App counts launcher setting', () => {
it('sends the stored counts launcher to the popup once the panel connects', async () => {
localStorage.setItem('pangular-pref:pill', 'true');
const fixture = TestBed.createComponent(App);
document.body.appendChild(host(fixture));
await settle(fixture);
const app = fixture.componentInstance;
const posted = vi.spyOn(app as unknown as { postToPopup(m: unknown): void }, 'postToPopup');
app.connected.set(true);
await settle(fixture);
expect(posted).toHaveBeenCalledWith({ type: 'pangular:popup-pill', pill: true });
localStorage.removeItem('pangular-pref:pill');
fixture.destroy();
});

it('saves the counts launcher and sends it to the popup on the page', async () => {
localStorage.removeItem('pangular-pref:pill');
const fixture = TestBed.createComponent(App);
document.body.appendChild(host(fixture));
await settle(fixture);
const app = fixture.componentInstance;
expect(app.pill()).toBe(false);
const sent = vi.spyOn(window.parent, 'postMessage');
app.setPill(true);
expect(app.pill()).toBe(true);
expect(localStorage.getItem('pangular-pref:pill')).toBe('true');
app.setPill(false);
expect(localStorage.getItem('pangular-pref:pill')).toBeNull();
// A top-level panel has no popup around it, so nothing is posted.
expect(sent).not.toHaveBeenCalled();
sent.mockRestore();
fixture.destroy();
});
});
2 changes: 1 addition & 1 deletion app/src/__tests__/component-value-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ describe('ComponentTree value expansion', () => {
const call = vi.fn<Call>(async () => []);
const fixture = await open(call);
const filter = el(fixture).querySelector<HTMLInputElement>(
'input[aria-label^="Filter inputs, outputs and properties"]',
'input[aria-label="Filter values"]',
)!;
type(filter, 'coun');
await settle(fixture);
Expand Down
30 changes: 24 additions & 6 deletions app/src/__tests__/di-inspector-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,28 @@ describe('injector tree page selection', () => {
});
});

describe('injector detail splitter', () => {
afterEach(() => {
document.body.innerHTML = '';
localStorage.clear();
});

it('puts a keyboard splitter between the tree and the detail that sizes the detail', async () => {
localStorage.setItem('pangular-pref:pane:injectors', '500');
const fixture = await render({ roots: [node('shared')], environment: [] });
const host = fixture.nativeElement as HTMLElement;
const bar = host.querySelector<HTMLElement>('.layout > app-splitter')!;
expect(bar.getAttribute('role')).toBe('separator');
expect(bar.getAttribute('aria-label')).toBe('Resize the injector detail');
expect(bar.getAttribute('aria-valuenow')).toBe('500');
const layout = host.querySelector<HTMLElement>('.layout')!;
expect(layout.style.getPropertyValue('--detail-size')).toBe('500px');
bar.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
await fixture.whenStable();
expect(layout.style.getPropertyValue('--detail-size')).toBe('516px');
});
});

describe('injector search box focus', () => {
afterEach(() => {
document.body.innerHTML = '';
Expand Down Expand Up @@ -328,9 +350,7 @@ describe('injector search box focus', () => {
clickNamed(host, 'Clear filters');
await fixture.whenStable();
expect(fixture.componentInstance.query()).toBe('');
expect(document.activeElement).toBe(
host.querySelector('input[aria-label="Find a token, component or injector"]'),
);
expect(document.activeElement).toBe(host.querySelector('.toolbar input[type="search"]'));
});

it('keeps focus on the search box after Clear filter in the source scan', async () => {
Expand All @@ -351,9 +371,7 @@ describe('injector search box focus', () => {
clickNamed(host, 'Clear filters');
await fixture.whenStable();
expect(host.textContent).toContain('CartService');
expect(document.activeElement).toBe(
host.querySelector('input[aria-label="Filter by token or file"]'),
);
expect(document.activeElement).toBe(host.querySelector('.toolbar input[type="search"]'));
});
});

Expand Down
8 changes: 3 additions & 5 deletions app/src/__tests__/forms-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe('FormsInspector fields', () => {
fixture.componentRef.setInput('rpc', fakeClient(fail, [form]));
await settle(fixture);
const box = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
'input[aria-label="Filter fields by path"]',
'.filters input[type="search"]',
)!;
box.value = 'email';
box.dispatchEvent(new Event('input'));
Expand All @@ -367,7 +367,7 @@ describe('FormsInspector field filters', () => {

function search(fixture: ComponentFixture<FormsInspector>): HTMLInputElement {
return (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
'input.filter[type="search"]',
'.filters input[type="search"]',
)!;
}

Expand Down Expand Up @@ -1023,9 +1023,7 @@ describe('FormsInspector timeline', () => {
expect(text(fixture)).toContain('Showing the latest 100 of 300 events');

const host = fixture.nativeElement as HTMLElement;
const search = host.querySelector<HTMLInputElement>(
'input[aria-label="Filter by field path"]',
)!;
const search = host.querySelector<HTMLInputElement>('app-forms-timeline input[type="search"]')!;
search.value = 'name';
search.dispatchEvent(new Event('input'));
await settle(fixture);
Expand Down
8 changes: 6 additions & 2 deletions app/src/__tests__/forms-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ describe('FormsTimeline filters', () => {
it('labels the path filter and the event type select', async () => {
const fixture = await setup(EVENTS);
const input = root(fixture).querySelector('input[type="search"]')!;
expect(input.getAttribute('aria-label')).toBe('Filter by field path');
expect(input.getAttribute('aria-label')).toBe('Filter');
expect(input.getAttribute('placeholder')).toBe('Filter by field path');
const select = root(fixture).querySelector('button[role="combobox"]')!;
expect(select.getAttribute('aria-label')).toBe('Event type');
expect(total(fixture)).toBe('6 events');
Expand Down Expand Up @@ -114,7 +115,10 @@ describe('FormsTimeline filters', () => {

it('announces the count when a filter changes, not on every new event', async () => {
const fixture = await setup(EVENTS);
const status = () => root(fixture).querySelector('[role="status"]')?.textContent?.trim();
const status = () =>
[...root(fixture).querySelectorAll('[role="status"]')]
.find((e) => !e.closest('app-search-field'))
?.textContent?.trim();
expect(root(fixture).querySelector('.total')?.hasAttribute('aria-live')).toBe(false);
await typePath(fixture, 'name');
const filtered = status();
Expand Down
11 changes: 8 additions & 3 deletions app/src/__tests__/network-timeline-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const host = () => fixture.nativeElement as HTMLElement;
const rows = () => [...host().querySelectorAll<HTMLElement>('button[data-call-id]')];
const rowIds = () => rows().map((b) => b.dataset['callId']);
const total = () => host().querySelector('.filters .total')!.textContent!.trim();
/** The filter count status, not the saved filter status of the search field. */
const pageStatus = () =>
[...host().querySelectorAll<HTMLElement>('.filters [role="status"]')].find(
(e) => !e.closest('app-search-field'),
)!;
const search = () => host().querySelector<HTMLInputElement>('#timeline-search')!;

async function type(value: string) {
Expand Down Expand Up @@ -63,7 +68,7 @@ describe('NetworkInspector timeline filters', () => {
it('labels every filter control and counts all calls', () => {
expect(rows()).toHaveLength(6);
expect(total()).toBe('6 of 6');
expect(search().getAttribute('aria-label')).toBe('Find a URL or method');
expect(search().getAttribute('aria-label')).toBe('Filter');
const failed = [...host().querySelectorAll<HTMLLabelElement>('.filters label')].find((l) =>
l.textContent?.includes('Failed only'),
);
Expand Down Expand Up @@ -152,7 +157,7 @@ describe('NetworkInspector timeline filters', () => {
});

it('announces the count when a filter changes, not when calls arrive', async () => {
const status = () => host().querySelector('.filters [role="status"]')!.textContent!.trim();
const status = () => pageStatus().textContent!.trim();
expect(host().querySelector('.filters .total')!.closest('[aria-live], [role="status"]')).toBe(
null,
);
Expand All @@ -173,7 +178,7 @@ describe('NetworkInspector timeline filters', () => {
});

it('announces a filter change again when the counts stay the same', async () => {
const raw = () => host().querySelector('.filters [role="status"]')!.textContent!;
const raw = () => pageStatus().textContent!;
await type('products');
const first = raw();
expect(first.trim()).toBe('2 of 6');
Expand Down
63 changes: 63 additions & 0 deletions app/src/__tests__/page-conventions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @vitest-environment node
// Checks over the page sources: every page uses the shared search field, and the pages with a
// list and a detail use the splitter. Kept apart from the component tests, which the Angular
// compiler transforms.
import { describe, expect, it } from 'vitest';

const PAGES = new URL('../pages/', import.meta.url).pathname;

/** The page sources by file name. The panel tsconfig has no Node types, so fs is a loose import. */
async function pageSources(): Promise<Map<string, string>> {
const fsName = 'node:fs';
const fs = (await import(/* @vite-ignore */ fsName)) as {
readFileSync(path: string, encoding: 'utf8'): string;
readdirSync(path: string): string[];
};
const files = fs.readdirSync(PAGES).filter((file) => file.endsWith('.ts'));
return new Map(files.map((file) => [file, fs.readFileSync(`${PAGES}${file}`, 'utf8')]));
}

describe('every page uses the shared search field', () => {
it('has no hand-written search input left in a page', async () => {
const raw = [...(await pageSources())]
.filter(([, source]) => /type="search"/.test(source))
.map(([file]) => file);
expect(raw).toEqual([]);
});

it('names no field anything other than Filter, except a second field on the same page', async () => {
const labels = [...(await pageSources())].flatMap(([file, source]) =>
[...source.matchAll(/<app-search-field[^>]*?\blabel="([^"]+)"/gs)].map(
(match) => `${file}: ${match[1]}`,
),
);
// The values filter sits under the component filter in the Components detail panel.
expect(labels).toEqual(['component-tree.ts: Filter values']);
});

it('uses Filter, not Find, in every placeholder', async () => {
const placeholders = [...(await pageSources()).values()].flatMap((source) =>
[...source.matchAll(/<app-search-field[^>]*?\bplaceholder="([^"]+)"/gs)].map(
(match) => match[1]!,
),
);
expect(placeholders.length).toBeGreaterThan(15);
for (const placeholder of placeholders) {
expect(placeholder).toMatch(/^Filter\b/);
expect(placeholder).not.toMatch(/…$/);
}
});
});

describe('pages with a splitter', () => {
it('size the injector detail and the store change list from a saved width', async () => {
const sources = await pageSources();
const page = (name: string) => sources.get(name) ?? '';
expect(page('di-inspector.ts')).toMatch(/<app-splitter[^>]*view="injectors"/s);
expect(page('di-inspector.ts')).toContain("storedPaneSize('injectors', 420)");
expect(page('store-inspector.ts')).toMatch(
/<app-splitter[^>]*view="store-log"[^>]*pane="before"/s,
);
expect(page('store-inspector.ts')).toContain("storedPaneSize('store-log', 300)");
});
});
6 changes: 5 additions & 1 deletion app/src/__tests__/route-timeline-clear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ function clearButton(fixture: ComponentFixture<unknown>): HTMLButtonElement {
}

function status(fixture: ComponentFixture<unknown>): string {
return el(fixture).querySelector('[role="status"]')?.textContent?.trim() ?? '';
return (
[...el(fixture).querySelectorAll('[role="status"]')]
.find((e) => !e.closest('app-search-field'))
?.textContent?.trim() ?? ''
);
}

afterEach(() => {
Expand Down
18 changes: 11 additions & 7 deletions app/src/__tests__/router-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe('RouteTree row actions', () => {
first.value = '42';
first.dispatchEvent(new Event('input'));

const filter = el(fixture).querySelector<HTMLInputElement>('input.filter')!;
const filter = el(fixture).querySelector<HTMLInputElement>('.filter input[type="search"]')!;
filter.value = 'lazy';
filter.dispatchEvent(new Event('input'));
await settle(fixture);
Expand Down Expand Up @@ -202,8 +202,9 @@ describe('RouteTree page switch', () => {
await settle(fixture);
expect(liveRegion(fixture).textContent?.trim()).toBe('');
const regions = el(fixture).querySelectorAll('[role="status"]');
expect(regions).toHaveLength(2);
expect(Array.from(regions).map((r) => r.textContent?.trim())).toEqual(['', '']);
// The row status, the filter count and the saved filter status of the search field.
expect(regions).toHaveLength(3);
expect(Array.from(regions).map((r) => r.textContent?.trim())).toEqual(['', '', '']);
});

it("drops page A's results and typed params after switching to page B", async () => {
Expand Down Expand Up @@ -268,7 +269,7 @@ describe('RouteTree filter count', () => {
];

function filterInput(fixture: ComponentFixture<unknown>): HTMLInputElement {
return el(fixture).querySelector<HTMLInputElement>('input.filter')!;
return el(fixture).querySelector<HTMLInputElement>('.filter input[type="search"]')!;
}

function type(fixture: ComponentFixture<unknown>, value: string) {
Expand Down Expand Up @@ -897,15 +898,18 @@ describe('Router filter boxes clear on Escape', () => {
offline,
);
await settle(fixture);
await typeThenEscape(fixture, el(fixture).querySelector<HTMLInputElement>('input.filter')!);
await typeThenEscape(
fixture,
el(fixture).querySelector<HTMLInputElement>('.filter input[type="search"]')!,
);
});

it('clears the navigation URL filter', async () => {
const fixture = mount(RouteTimeline, page({ navigations: [nav(1, '/users')] }), offline);
await settle(fixture);
await typeThenEscape(
fixture,
el(fixture).querySelector<HTMLInputElement>('input[aria-label="Filter navigations by URL"]')!,
el(fixture).querySelector<HTMLInputElement>('.toolbar input[type="search"]')!,
);
});

Expand All @@ -924,7 +928,7 @@ describe('Router filter boxes clear on Escape', () => {
await settle(fixture);
await typeThenEscape(
fixture,
el(fixture).querySelector<HTMLInputElement>('input[aria-label="Filter source routes"]')!,
el(fixture).querySelector<HTMLInputElement>('.toolbar input[type="search"]')!,
);
});
});
Loading
Loading