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 .claude/agents/a11y-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ tools: Read, Grep, Glob, Bash

You review accessibility and visual consistency. You don't edit files; you report.

Follow the browser checks in the `devtools-verify` skill: run axe on each page (the panel is dark only, so one color scheme), check horizontal overflow at 1280px and 360px, and walk every interactive element with the keyboard (focus visible, arrow keys in trees and lists, `Escape` closes popups and clears search). Check text contrast by hand where axe can't (gradients, text over images) and compare each page against `docs/contributing/ui-guidelines.md`.
Follow the browser checks in the `devtools-verify` skill: run axe on each page in both dark and light color schemes, check horizontal overflow at 1280px and 360px, and walk every interactive element with the keyboard (focus visible, arrow keys in trees and lists, `Escape` closes popups and clears search). Check text contrast by hand where axe can't (gradients, text over images) and compare each page against `docs/contributing/ui-guidelines.md`.

Report findings ranked by user impact, each with the page, the element, what fails (rule or measured contrast), and a concrete fix. Say which pages you checked and how.
4 changes: 2 additions & 2 deletions .claude/skills/devtools-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ NO_COLOR=1 pnpm exec ngc -p app/tsconfig.json --noEmit

and treat any `error TS` or `error NG` line as a failure. Strip color codes before grepping the output, or errors slip through.

`pnpm test:axe` needs Chromium (`pnpm exec playwright install chromium` once). It runs axe on every panel tab and hub view against a static report of Angular Travel. The panel is dark only, so it runs in the dark color scheme. It does not replace the browser checks below, which use real data from the demos.
`pnpm test:axe` needs Chromium (`pnpm exec playwright install chromium` once). It runs axe on every panel tab and hub view against a static report of Angular Travel in both dark and light color schemes. It does not replace the browser checks below, which use real data from the demos.

## 2. Run the demos

Expand All @@ -54,7 +54,7 @@ Open the panel through the amber launcher on the page, at `/__devframes/`, and d

With Playwright and `@axe-core/playwright` (install them in a scratch folder, not in the repo):

- Every page you touched (the panel is dark only): axe reports no violations, there are no page errors, and `document.documentElement.scrollWidth <= innerWidth` at 1280px and 360px wide.
- Every page you touched in both dark and light color schemes: axe reports no violations, there are no page errors, and `document.documentElement.scrollWidth <= innerWidth` at 1280px and 360px wide.
- Hub docks: clicking each rail button shows the matching view and only one frame (the rail selection and the content must match after fast switching and after a reload).
- The feature itself, with real data from the demo app (for example `/examples/<area>`).

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pnpm skills:check # agent skills and roles
pnpm exec ngc -p app/tsconfig.json --noEmit # panel template check
```

For UI changes, also check the pages in a browser with axe, at a wide and a narrow width (the panel is dark only). The [devtools-verify skill](.claude/skills/devtools-verify/SKILL.md) lists the exact steps.
For UI changes, also check the pages in a browser with axe in both dark and light color schemes, at a wide and a narrow width. The [devtools-verify skill](.claude/skills/devtools-verify/SKILL.md) lists the exact steps.

## Submit a pull request

Expand Down
11 changes: 4 additions & 7 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0b0e" />
<style>
html {
background: #0b0b0e;
}
</style>
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0b0b0e" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<script src="./theme-init.js"></script>
<title>Angular DevTools</title>
<link rel="stylesheet" href="./src/styles/main.scss" />
</head>
Expand Down
10 changes: 10 additions & 0 deletions app/public/theme-init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
(function () {
const param = new URLSearchParams(location.search).get('theme');
const theme =
param === 'dark' ? 'dark' : param === 'default' || param === 'light' ? 'light' : null;
if (theme) document.documentElement.dataset.theme = theme;
const dark = theme ? theme === 'dark' : !matchMedia('(prefers-color-scheme: light)').matches;
const style = document.createElement('style');
style.textContent = 'html{background:' + (dark ? '#0b0b0e' : '#ffffff') + '}';
document.head.prepend(style);
})();
78 changes: 78 additions & 0 deletions app/src/__tests__/hub-rail-style.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { styleHubRail } from '../hub-rail-style';

function makeDock() {
const shadow = document.createElement('devframes-dock-standalone');
shadow.attachShadow({ mode: 'open' });
document.body.append(shadow);
return shadow;
}

afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
});

describe('styleHubRail', () => {
it('injects a style tag into the shadow root', () => {
const dock = makeDock();
styleHubRail(document, 'dark');
expect(dock.shadowRoot!.querySelector('style[data-ng-devtools]')).not.toBeNull();
});

it('does nothing when document is null', () => {
expect(() => styleHubRail(null, 'dark')).not.toThrow();
});

it('sets dark iframe background for dark theme', () => {
makeDock();
styleHubRail(document, 'dark');
const style = document
.querySelector('devframes-dock-standalone')!
.shadowRoot!.querySelector<HTMLStyleElement>('style[data-ng-devtools]')!;
expect(style.textContent).toContain('#0b0b0e');
});

it('sets light iframe background for light theme', () => {
makeDock();
styleHubRail(document, 'light');
const style = document
.querySelector('devframes-dock-standalone')!
.shadowRoot!.querySelector<HTMLStyleElement>('style[data-ng-devtools]')!;
expect(style.textContent).toContain('#ffffff');
});

it('updates existing style tag when called again with a different theme', () => {
makeDock();
styleHubRail(document, 'dark');
styleHubRail(document, 'light');
const shadows = document.querySelectorAll('devframes-dock-standalone');
const styleTags = [...shadows].flatMap((el) => [
...(el.shadowRoot?.querySelectorAll('style[data-ng-devtools]') ?? []),
]);
expect(styleTags).toHaveLength(1);
expect(styleTags[0].textContent).toContain('#ffffff');
});

it('retries when the shadow host is not yet in the DOM', () => {
vi.useFakeTimers();
styleHubRail(document, 'dark');
expect(document.querySelector('devframes-dock-standalone')).toBeNull();

const dock = makeDock();
vi.advanceTimersByTime(100);
expect(dock.shadowRoot!.querySelector('style[data-ng-devtools]')).not.toBeNull();
});

it('deduplicates retry timers for the same document', () => {
vi.useFakeTimers();
styleHubRail(document, 'dark');
styleHubRail(document, 'light');
const dock = makeDock();
vi.advanceTimersByTime(100);
const styles = dock.shadowRoot!.querySelectorAll('style[data-ng-devtools]');
expect(styles).toHaveLength(1);
expect(styles[0].textContent).toContain('#ffffff');
});
});
137 changes: 137 additions & 0 deletions app/src/__tests__/theme.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { TestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ThemeService } from '../theme.service';

try {
TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
} catch {
// already initialized
}

afterEach(() => {
delete document.documentElement.dataset['theme'];
TestBed.resetTestingModule();
vi.unstubAllGlobals();
});

function stubColorScheme(light: boolean) {
let listener: ((e: { matches: boolean }) => void) | undefined;
const query = {
matches: light,
addEventListener: (_: string, fn: typeof listener) => (listener = fn),
removeEventListener: () => (listener = undefined),
};
vi.stubGlobal('matchMedia', () => query);
return (next: boolean) => listener?.({ matches: next });
}

describe('ThemeService', () => {
it('defaults to dark when no data-theme attribute is present', () => {
const svc = TestBed.inject(ThemeService);
expect(svc.current()).toBe('dark');
});

it('reads dark from the data-theme attribute on bootstrap', () => {
document.documentElement.dataset['theme'] = 'dark';
const svc = TestBed.inject(ThemeService);
expect(svc.current()).toBe('dark');
});

it('reads light from the data-theme attribute on bootstrap', () => {
document.documentElement.dataset['theme'] = 'light';
const svc = TestBed.inject(ThemeService);
expect(svc.current()).toBe('light');
});

it('updates to light on a ng-devtools:theme-change postMessage', () => {
const svc = TestBed.inject(ThemeService);
expect(svc.current()).toBe('dark');

window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'ng-devtools:theme-change', theme: 'default' },
source: window.parent,
}),
);

expect(svc.current()).toBe('light');
expect(document.documentElement.dataset['theme']).toBe('light');
});

it('updates to dark on a ng-devtools:theme-change postMessage', () => {
document.documentElement.dataset['theme'] = 'light';
const svc = TestBed.inject(ThemeService);
expect(svc.current()).toBe('light');

window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'ng-devtools:theme-change', theme: 'dark' },
source: window.parent,
}),
);

expect(svc.current()).toBe('dark');
expect(document.documentElement.dataset['theme']).toBe('dark');
});

it('ignores postMessages not from the parent frame', () => {
const svc = TestBed.inject(ThemeService);

window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'ng-devtools:theme-change', theme: 'default' },
source: null,
}),
);

expect(svc.current()).toBe('dark');
});

it('ignores postMessages with unknown types', () => {
const svc = TestBed.inject(ThemeService);

window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'some-other-type', theme: 'default' },
source: window.parent,
}),
);

expect(svc.current()).toBe('dark');
});

it('follows the system color scheme when nothing pins a theme', () => {
const flip = stubColorScheme(true);
const svc = TestBed.inject(ThemeService);
expect(svc.current()).toBe('light');

flip(false);
expect(svc.current()).toBe('dark');
});

it('stops following the system once DevTools sends a theme', () => {
const flip = stubColorScheme(false);
const svc = TestBed.inject(ThemeService);

window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'ng-devtools:theme-change', theme: 'dark' },
source: window.parent,
}),
);
flip(true);

expect(svc.current()).toBe('dark');
});

it('ignores the system color scheme when the URL pinned a theme', () => {
document.documentElement.dataset['theme'] = 'dark';
const flip = stubColorScheme(true);
const svc = TestBed.inject(ThemeService);

flip(true);
expect(svc.current()).toBe('dark');
});
});
33 changes: 27 additions & 6 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
afterNextRender,
afterRenderEffect,
computed,
effect,
inject,
linkedSignal,
signal,
Expand All @@ -28,6 +29,7 @@ import { NetworkInspector } from './pages/network-inspector';
import { ComingSoon, type ComingSoonInfo } from './pages/coming-soon';
import { TabIcon } from './pages/tab-icon';
import { styleHubRail } from './hub-rail-style';
import { ThemeService } from './theme.service';
import { followHubDocks, selectHubDock } from './hub-dock-sync';
import { panelConfig, tabEnabled } from './devtools-config';
import { hostPageId, scopeToPage } from './page-id';
Expand Down Expand Up @@ -64,6 +66,14 @@ const VIEW_ACCENT: Partial<Record<View, string>> = {
capacitor: '#53b9ff',
};

const VIEW_ACCENT_LIGHT: Partial<Record<View, string>> = {
ngrx: '#a21caf',
analog: '#be123c',
'angular-native': '#be123c',
nativescript: '#3448c5',
capacitor: '#0369a1',
};

const VIEW_TAB: Partial<Record<View, Tab>> = { ngrx: 'store', analog: 'analog' };
const TAB_VIEW: Partial<Record<Tab, View>> = { store: 'ngrx', analog: 'analog' };
const VIEW_TABS: Partial<Record<View, Tab[]>> = {
Expand Down Expand Up @@ -441,6 +451,10 @@ function readView(): View | null {
-webkit-background-clip: text;
background-clip: text;
color: transparent;

@include m.light {
background-image: linear-gradient(90deg, #be123c 0%, #a21caf 50%, #6d28d9 100%);
}
}
nav {
position: relative;
Expand Down Expand Up @@ -700,7 +714,8 @@ export class App implements OnInit, OnDestroy {
readonly view = signal<View | null>(readView());
readonly viewAccent = computed(() => {
const view = this.view();
return (view && VIEW_ACCENT[view]) ?? null;
const accents = this.themeService.current() === 'light' ? VIEW_ACCENT_LIGHT : VIEW_ACCENT;
return (view && accents[view]) ?? null;
});
readonly title = computed(() => {
const view = this.view();
Expand Down Expand Up @@ -769,6 +784,7 @@ export class App implements OnInit, OnDestroy {
private readonly nav = viewChild<ElementRef<HTMLElement>>('nav');
private readonly main = viewChild<ElementRef<HTMLElement>>('main');
private readonly injector = inject(Injector);
private readonly themeService = inject(ThemeService);
private navObserver?: ResizeObserver;
readonly navFade = signal({ start: false, end: false });

Expand All @@ -787,6 +803,16 @@ export class App implements OnInit, OnDestroy {
this.measureNav();
});
});
effect(() => {
const theme = this.themeService.current();
untracked(() => {
try {
if (window.parent !== window) styleHubRail(window.parent.document, theme);
} catch {
// cross-origin parent
}
});
});
}

measureNav() {
Expand Down Expand Up @@ -814,11 +840,6 @@ export class App implements OnInit, OnDestroy {
if (this.view()) {
this.stopFollowing = followHubDocks(HUB_VIEWS, (view) => this.showView(view));
}
try {
if (window.parent !== window) styleHubRail(window.parent.document);
} catch {
// a cross origin parent cannot be styled
}
const restored = initialTab(
location.hash,
storedTab(this.tabScope()),
Expand Down
Loading
Loading