diff --git a/.claude/agents/a11y-reviewer.md b/.claude/agents/a11y-reviewer.md index b5206e1..b5c045c 100644 --- a/.claude/agents/a11y-reviewer.md +++ b/.claude/agents/a11y-reviewer.md @@ -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. diff --git a/.claude/skills/devtools-verify/SKILL.md b/.claude/skills/devtools-verify/SKILL.md index d71a47b..38e3fd1 100644 --- a/.claude/skills/devtools-verify/SKILL.md +++ b/.claude/skills/devtools-verify/SKILL.md @@ -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 @@ -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/`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d11e401..9d986eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/app/index.html b/app/index.html index c9229c7..5b9f4c4 100644 --- a/app/index.html +++ b/app/index.html @@ -3,13 +3,10 @@ - - - + + + + Angular DevTools diff --git a/app/public/theme-init.js b/app/public/theme-init.js new file mode 100644 index 0000000..1863a69 --- /dev/null +++ b/app/public/theme-init.js @@ -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); +})(); diff --git a/app/src/__tests__/hub-rail-style.test.ts b/app/src/__tests__/hub-rail-style.test.ts new file mode 100644 index 0000000..1e4be50 --- /dev/null +++ b/app/src/__tests__/hub-rail-style.test.ts @@ -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('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('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'); + }); +}); diff --git a/app/src/__tests__/theme.service.test.ts b/app/src/__tests__/theme.service.test.ts new file mode 100644 index 0000000..dd3a520 --- /dev/null +++ b/app/src/__tests__/theme.service.test.ts @@ -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'); + }); +}); diff --git a/app/src/app.ts b/app/src/app.ts index f5f8bf6..634f24d 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -5,6 +5,7 @@ import { afterNextRender, afterRenderEffect, computed, + effect, inject, linkedSignal, signal, @@ -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'; @@ -64,6 +66,14 @@ const VIEW_ACCENT: Partial> = { capacitor: '#53b9ff', }; +const VIEW_ACCENT_LIGHT: Partial> = { + ngrx: '#a21caf', + analog: '#be123c', + 'angular-native': '#be123c', + nativescript: '#3448c5', + capacitor: '#0369a1', +}; + const VIEW_TAB: Partial> = { ngrx: 'store', analog: 'analog' }; const TAB_VIEW: Partial> = { store: 'ngrx', analog: 'analog' }; const VIEW_TABS: Partial> = { @@ -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; @@ -700,7 +714,8 @@ export class App implements OnInit, OnDestroy { readonly view = signal(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(); @@ -769,6 +784,7 @@ export class App implements OnInit, OnDestroy { private readonly nav = viewChild>('nav'); private readonly main = viewChild>('main'); private readonly injector = inject(Injector); + private readonly themeService = inject(ThemeService); private navObserver?: ResizeObserver; readonly navFade = signal({ start: false, end: false }); @@ -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() { @@ -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()), diff --git a/app/src/hub-rail-style.ts b/app/src/hub-rail-style.ts index c896d30..dbeac6f 100644 --- a/app/src/hub-rail-style.ts +++ b/app/src/hub-rail-style.ts @@ -1,4 +1,9 @@ -const ACTIVE_DOCK_STYLE = ` +function activeDockStyle(theme: 'dark' | 'light'): string { + const bg = theme === 'light' ? '#ffffff' : '#0b0b0e'; + const accent = theme === 'light' ? '#92400e' : '#f5a524'; + const accentSoft = theme === 'light' ? 'rgba(146, 64, 14, 0.12)' : 'rgba(245, 165, 36, 0.16)'; + const accentGlow = theme === 'light' ? 'rgba(146, 64, 14, 0.50)' : 'rgba(245, 165, 36, 0.60)'; + return ` .devframes-dock-entry button { transition: opacity 0.2s, filter 0.2s, background-color 0.2s, transform 0.3s; } @@ -12,17 +17,17 @@ const ACTIVE_DOCK_STYLE = ` filter: none; } .devframes-dock-entry button:focus-visible { - outline: 2px solid #f5a524; + outline: 2px solid ${accent}; outline-offset: 2px; } .devframes-dock-entry button.scale-120 { transform: none; - background: rgba(245, 165, 36, 0.16); - box-shadow: inset 0 0 0 1px rgba(245, 165, 36, 0.6); + background: ${accentSoft}; + box-shadow: inset 0 0 0 1px ${accentGlow}; } iframe { - background: #0b0b0e; - color-scheme: dark; + background: ${bg}; + color-scheme: ${theme}; } @media (prefers-reduced-motion: reduce) { .devframes-dock-entry button { @@ -30,16 +35,40 @@ const ACTIVE_DOCK_STYLE = ` } } `; +} + +const pendingRetries = new Map>(); -export function styleHubRail(doc: Document | null | undefined, attempts = 50): void { +export function styleHubRail( + doc: Document | null | undefined, + theme: 'dark' | 'light' = 'dark', + attempts = 50, +): void { const root = doc?.querySelector('devframes-dock-standalone')?.shadowRoot; if (root) { - if (root.querySelector('style[data-ng-devtools]')) return; - const style = doc!.createElement('style'); - style.dataset['ngDevtools'] = ''; - style.textContent = ACTIVE_DOCK_STYLE; - root.append(style); + if (doc) { + const existing = pendingRetries.get(doc); + if (existing !== undefined) { + clearTimeout(existing); + pendingRetries.delete(doc); + } + } + let style = root.querySelector('style[data-ng-devtools]'); + if (!style) { + style = doc!.createElement('style'); + style.dataset['ngDevtools'] = ''; + root.append(style); + } + style.textContent = activeDockStyle(theme); return; } - if (attempts > 0) setTimeout(() => styleHubRail(doc, attempts - 1), 100); + if (attempts > 0 && doc) { + const existing = pendingRetries.get(doc); + if (existing !== undefined) clearTimeout(existing); + const timer = setTimeout(() => { + pendingRetries.delete(doc); + styleHubRail(doc, theme, attempts - 1); + }, 100); + pendingRetries.set(doc, timer); + } } diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index e7bb225..db7188e 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -1046,10 +1046,18 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } `, styles: ` + @use 'mixins' as m; + :host { --good: var(--ok); --bad: var(--danger); --info: #60a5fa; + + @include m.light { + --info: #1d4ed8; + } + } + :host { --mono: var(--font-mono); display: grid; gap: 16px; @@ -1597,60 +1605,125 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } .pill.live { border-color: var(--accent-line); - background: var(--accent-soft); + background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--accent); } .pill[data-kind='layout'] { border-color: #94a3b8; color: #e2e8f0; + + @include m.light { + border-color: #64748b; + color: #475569; + } } .pill[data-kind='markdown'] { border-color: #0ea5e9; color: #bae6fd; + + @include m.light { + border-color: #0284c7; + color: #0369a1; + } } .pill[data-kind='load'] { border-color: #2dd4bf; color: #99f6e4; + + @include m.light { + border-color: #0d9488; + color: #0f766e; + } } .pill[data-mode='ssr'] { border-color: #3b82f6; color: #bfdbfe; + + @include m.light { + border-color: #2563eb; + color: #1d4ed8; + } } .pill[data-mode='ssg'] { border-color: #22c55e; color: #bbf7d0; + + @include m.light { + border-color: #16a34a; + color: #166534; + } } .pill[data-mode='client'] { border-color: #eab308; color: #fef08a; + + @include m.light { + border-color: #92400e; + color: #713f12; + } } .pill[data-mode='cached'] { border-color: #a855f7; color: #e9d5ff; + + @include m.light { + border-color: #9333ea; + color: #7e22ce; + } } .pill[data-mode='redirect'] { border-color: #94a3b8; color: #e2e8f0; + + @include m.light { + border-color: #64748b; + color: #475569; + } } .pill[data-call='page'] { border-color: #3b82f6; color: #bfdbfe; + + @include m.light { + border-color: #2563eb; + color: #1d4ed8; + } } .pill[data-call='load'] { border-color: #2dd4bf; color: #99f6e4; + + @include m.light { + border-color: #0d9488; + color: #0f766e; + } } .pill[data-call='fn'] { border-color: #14b8a6; color: #99f6e4; + + @include m.light { + border-color: #0d9488; + color: #0f766e; + } } .pill[data-call='api'] { border-color: #f97316; color: #fed7aa; + + @include m.light { + border-color: #c2410c; + color: #9a3412; + } } .pill[data-call='action'] { border-color: #ec4899; color: #fbcfe8; + + @include m.light { + border-color: #db2777; + color: #9d174d; + } } [data-tone='good'].pill { border-color: color-mix(in srgb, var(--ok) 30%, transparent); @@ -1673,6 +1746,10 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth border-color: color-mix(in srgb, var(--info) 30%, transparent); background: color-mix(in srgb, var(--info) 12%, transparent); color: #bfdbfe; + + @include m.light { + color: var(--info); + } } .status { @@ -1710,22 +1787,40 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth border-color: color-mix(in srgb, #7dd3fc 30%, transparent); background: color-mix(in srgb, #7dd3fc 12%, transparent); color: #7dd3fc; + + @include m.light { + border-color: color-mix(in srgb, #0369a1 30%, transparent); + background: color-mix(in srgb, #0369a1 8%, transparent); + color: #0369a1; + } } .method[data-method='POST'] { border-color: color-mix(in srgb, var(--ok) 30%, transparent); background: color-mix(in srgb, var(--ok) 12%, transparent); color: #86efac; + + @include m.light { + color: var(--ok); + } } .method[data-method='PUT'], .method[data-method='PATCH'] { border-color: color-mix(in srgb, var(--warn) 30%, transparent); background: color-mix(in srgb, var(--warn) 12%, transparent); color: #fde68a; + + @include m.light { + color: var(--warn); + } } .method[data-method='DELETE'] { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: color-mix(in srgb, var(--danger) 12%, transparent); color: #fecaca; + + @include m.light { + color: var(--danger); + } } .request { min-width: 260px; diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index fe55a89..02de8c0 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -55,7 +55,7 @@ const NULL_ID = 'inj-null'; const KIND_TONE: Record = { component: 'var(--accent)', - directive: '#7cb4ff', + directive: 'var(--directive)', environment: 'var(--ok)', null: 'var(--text-3)', }; @@ -494,9 +494,14 @@ function isTree(value: unknown): value is InjectorNode[] { @use 'mixins' as m; :host { + --directive: #7cb4ff; display: block; color: var(--text); font-size: 13px; + + @include m.light { + --directive: #1d4ed8; + } } .mono { font-family: var(--font-mono); diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts index 1af6594..8eba516 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -1124,6 +1124,10 @@ function countFields(node: FormFieldNode): number { .errors code { color: #fde68a; overflow-wrap: anywhere; + + @include m.light { + color: var(--accent); + } } .value .muted { margin-top: 2px; diff --git a/app/src/pages/forms-report.ts b/app/src/pages/forms-report.ts index 5686a57..3128a19 100644 --- a/app/src/pages/forms-report.ts +++ b/app/src/pages/forms-report.ts @@ -262,6 +262,10 @@ export class FormsSubmit { } .path code { color: #fde68a; + + @include m.light { + color: var(--accent); + } } .message, .fix { diff --git a/app/src/pages/forms-timeline.ts b/app/src/pages/forms-timeline.ts index 75e4df7..4a3d86c 100644 --- a/app/src/pages/forms-timeline.ts +++ b/app/src/pages/forms-timeline.ts @@ -262,6 +262,9 @@ const ORIGINS = ['all', 'user', 'code', 'devtools'] as const; box-shadow: inset 2px 0 0 var(--accent); color: var(--text-strong); } + .events li[data-type='submit'] time { + color: var(--text-2); + } time { color: var(--text-3); font-family: var(--font-mono); @@ -284,6 +287,10 @@ const ORIGINS = ['all', 'user', 'code', 'devtools'] as const; .path { color: #fde68a; overflow-wrap: anywhere; + + @include m.light { + color: var(--accent); + } } .event-type { color: var(--text-strong); diff --git a/app/src/pages/forms-types.ts b/app/src/pages/forms-types.ts index 8e63949..83a529b 100644 --- a/app/src/pages/forms-types.ts +++ b/app/src/pages/forms-types.ts @@ -159,6 +159,8 @@ export function plain(text: string | null): string { } export const FORMS_STYLES = ` + @use 'mixins' as m; + .muted { color: var(--text-2); } @@ -231,6 +233,10 @@ export const FORMS_STYLES = ` color: #1f0707; font-weight: 600; box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); + + @include m.light { + color: #fff; + } } .field-input { min-width: 0; @@ -300,17 +306,17 @@ export const FORMS_STYLES = ` } .tag[data-tone='ok'] { border-color: color-mix(in srgb, var(--ok) 30%, transparent); - background: color-mix(in srgb, var(--ok) 12%, transparent); + background: color-mix(in srgb, var(--ok) 12%, var(--surface)); color: var(--ok); } .tag[data-tone='warn'] { border-color: color-mix(in srgb, var(--warn) 30%, transparent); - background: color-mix(in srgb, var(--warn) 12%, transparent); + background: color-mix(in srgb, var(--warn) 12%, var(--surface)); color: var(--warn); } .tag[data-tone='bad'] { border-color: color-mix(in srgb, var(--danger) 30%, transparent); - background: color-mix(in srgb, var(--danger) 12%, transparent); + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); color: var(--danger); } .status { diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index df75f56..fa567f9 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -132,7 +132,7 @@ const KIND_COLORS: Record = { 'contentChild.required (signal)': '#fda4af', 'contentChildren (signal)': '#fda4af', resource: '#06b6d4', - unknown: 'var(--text-2)', + unknown: '#a1a1aa', }; @Component({ @@ -782,7 +782,7 @@ const KIND_COLORS: Record = { line-height: 18px; padding: 0 8px; border-radius: 99px; - color: var(--bg); + color: #0b0b0e; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; @@ -1170,6 +1170,10 @@ const KIND_COLORS: Record = { color: #93c5fd; background: color-mix(in srgb, #60a5fa 12%, transparent); border-color: color-mix(in srgb, #60a5fa 30%, transparent); + + @include m.light { + @include m.soft(#1d4ed8); + } } .missed { color: var(--warn); diff --git a/app/src/styles/_base.scss b/app/src/styles/_base.scss index 0980100..6badac5 100644 --- a/app/src/styles/_base.scss +++ b/app/src/styles/_base.scss @@ -13,6 +13,7 @@ html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; + background: var(--bg); } body { @@ -68,6 +69,16 @@ select:not([multiple]) { cursor: pointer; } +@media (prefers-color-scheme: light) { + :root:not([data-theme='dark']) select:not([multiple]) { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235f5f68' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + } +} + +:root[data-theme='light'] select:not([multiple]) { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235f5f68' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); +} + ::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); } diff --git a/app/src/styles/_mixins.scss b/app/src/styles/_mixins.scss index d3ad5f0..77c3ecb 100644 --- a/app/src/styles/_mixins.scss +++ b/app/src/styles/_mixins.scss @@ -1,3 +1,5 @@ +@use 'sass:string'; + @mixin focus-ring($offset: 2px) { outline: 2px solid var(--accent); outline-offset: $offset; @@ -43,3 +45,18 @@ @mixin enter($duration: 0.35s) { animation: enter $duration var(--ease) both; } + +@mixin light { + $host: ''; + @if not string.index('#{&}', ':host') { + $host: ':host '; + } + @media (prefers-color-scheme: light) { + :root:not([data-theme='dark']) #{$host}& { + @content; + } + } + :root[data-theme='light'] #{$host}& { + @content; + } +} diff --git a/app/src/styles/_palette.scss b/app/src/styles/_palette.scss index f0f3439..f318f56 100644 --- a/app/src/styles/_palette.scss +++ b/app/src/styles/_palette.scss @@ -13,21 +13,49 @@ $neutrals: ( text-3: #8e8e99, ); +$neutrals-light: ( + bg: #ffffff, + surface: #f7f7f9, + surface-2: #f0f0f3, + surface-3: #e8e8ec, + border: #e2e2e7, + border-strong: #c8c8d0, + text: #18181b, + text-strong: #0a0a0d, + text-2: #52525b, + text-3: #5f5f68, +); + $accents: ( amber: ( base: #f5a524, hover: #fbbf4d, ink: #1c1300, + light: ( + base: #92400e, + hover: #a04510, + ink: #ffffff, + ), ), ember: ( base: #fb7a3c, hover: #fd9a67, ink: #1f0a00, + light: ( + base: #9a3412, + hover: #b04016, + ink: #ffffff, + ), ), gold: ( base: #eab308, hover: #facc15, ink: #1a1400, + light: ( + base: #713f12, + hover: #854d0e, + ink: #ffffff, + ), ), ); @@ -37,6 +65,16 @@ $status: ( danger: #f87171, ); -@function accent($name, $key) { - @return map.get(map.get($accents, $name), $key); +$status-light: ( + ok: #166534, + warn: #92400e, + danger: #b91c1c, +); + +@function accent($name, $key, $subkey: null) { + $a: map.get($accents, $name); + @if $subkey { + @return map.get(map.get($a, $key), $subkey); + } + @return map.get($a, $key); } diff --git a/app/src/styles/_theme.scss b/app/src/styles/_theme.scss index e6ab20d..2f3a4fd 100644 --- a/app/src/styles/_theme.scss +++ b/app/src/styles/_theme.scss @@ -33,3 +33,31 @@ $ease: cubic-bezier(0.2, 0.8, 0.2, 1); --font-sans: system-ui, -apple-system, 'Segoe UI', sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; } + +@mixin light-tokens { + color-scheme: light; + + @each $name, $value in p.$neutrals-light { + --#{$name}: #{$value}; + } + + --accent: #{p.accent($accent, light, base)}; + --accent-hover: #{p.accent($accent, light, hover)}; + --accent-ink: #{p.accent($accent, light, ink)}; + + @each $name, $value in p.$status-light { + --#{$name}: #{$value}; + } + + --shadow: 0 2px 12px -4px rgba(0, 0, 0, 0.12); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme='dark']) { + @include light-tokens; + } +} + +:root[data-theme='light'] { + @include light-tokens; +} diff --git a/app/src/theme.service.ts b/app/src/theme.service.ts new file mode 100644 index 0000000..043e8ca --- /dev/null +++ b/app/src/theme.service.ts @@ -0,0 +1,96 @@ +import { DestroyRef, Service, effect, inject, signal } from '@angular/core'; + +export type Theme = 'dark' | 'light'; + +@Service() +export class ThemeService { + readonly current = signal('dark'); + + constructor() { + const destroyRef = inject(DestroyRef); + const colorRoot = getHubColorRoot(); + const attr = document.documentElement.dataset['theme']; + let pinned = attr === 'light' || attr === 'dark'; + + if (attr === 'light' || attr === 'dark') { + this.current.set(attr); + } else if (colorRoot) { + pinned = true; + this.apply(hubTheme(colorRoot)); + } else { + const query = prefersLight(); + if (query) { + this.current.set(query.matches ? 'light' : 'dark'); + const onChange = (e: MediaQueryListEvent) => { + if (!pinned) this.current.set(e.matches ? 'light' : 'dark'); + }; + query.addEventListener('change', onChange); + destroyRef.onDestroy(() => query.removeEventListener('change', onChange)); + } + } + + let channel: BroadcastChannel | undefined; + try { + channel = new BroadcastChannel('ng-devtools:theme'); + } catch {} + effect(() => { + const theme = this.current(); + try { + channel?.postMessage(theme); + } catch {} + try { + let w: Window = window; + while (w !== w.parent) { + w = w.parent; + w.postMessage({ type: 'ng-devtools:theme-change', theme }, '*'); + } + } catch {} + }); + + const onMessage = (e: MessageEvent) => { + if (e.source !== window.parent) return; + const msg = e.data as { type?: unknown; theme?: unknown } | null; + if (msg?.type !== 'ng-devtools:theme-change') return; + pinned = true; + this.apply(msg.theme === 'dark' ? 'dark' : 'light'); + }; + window.addEventListener('message', onMessage); + + let observer: MutationObserver | undefined; + if (colorRoot) { + observer = new MutationObserver(() => this.apply(hubTheme(colorRoot))); + observer.observe(colorRoot, { attributes: true, attributeFilter: ['class'] }); + } + + destroyRef.onDestroy(() => { + window.removeEventListener('message', onMessage); + observer?.disconnect(); + channel?.close(); + }); + } + + private apply(theme: Theme) { + this.current.set(theme); + document.documentElement.dataset['theme'] = theme; + } +} + +function prefersLight(): MediaQueryList | null { + try { + return window.matchMedia?.('(prefers-color-scheme: light)') ?? null; + } catch { + return null; + } +} + +function hubTheme(colorRoot: Element): Theme { + return colorRoot.classList.contains('dark') ? 'dark' : 'light'; +} + +function getHubColorRoot(): Element | null { + try { + return window.frameElement?.closest('.devframes-color-root') ?? null; + } catch { + return null; + } +} diff --git a/app/src/ui/select.ts b/app/src/ui/select.ts index c1ef744..c429969 100644 --- a/app/src/ui/select.ts +++ b/app/src/ui/select.ts @@ -91,6 +91,8 @@ let nextId = 0; } `, styles: ` + @use 'mixins' as m; + :host { position: relative; display: inline-flex; @@ -185,6 +187,10 @@ let nextId = 0; 0 16px 40px -12px rgb(0 0 0 / 0.7), 0 0 0 1px rgb(0 0 0 / 0.2); overscroll-behavior: contain; + + @include m.light { + box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.25); + } } .list.up { top: auto; diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index 82eda1f..4a92380 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -110,17 +110,22 @@ If none answers, the status view lists every URL it tried and links to the setup ### Loading the UI -The panel loads `ui/index.html` with two query parameters: +The panel loads `ui/index.html` with three query parameters: | Parameter | Value | | --------- | -------------------------------------------------------------------------------------- | | `baseURL` | The path that served the connection file, on the origin of the page. | | `pageId` | The `ng-devtools-page-id` value the overlay keeps in `sessionStorage`, when it is set. | +| `theme` | The DevTools theme name, `dark` or `default`. | Outside the extension, the UI accepts a `baseURL` only on its own origin. Inside the extension, it accepts any `http` or `https` URL. The panel only passes hosts the extension can reach. On each navigation of the inspected page, the panel shows its status view again and repeats the whole search. +### Theme + +The panel follows the DevTools theme. `panel-bridge.js` reads `chrome.devtools.panels.themeName` at startup, sets `data-theme` on `panel.html` so the status view matches, and passes the name to the UI as the `theme` parameter (`dark` or `default`, which is light). Its `setThemeChangeHandler` updates `panel.html` and posts an `ng-devtools:theme-change` message to the UI frame when DevTools switches theme. `ThemeService` in the panel sets `data-theme` on ``, and the CSS tokens in `app/src/styles/_theme.scss` follow it. + ### Elements panel selection The overlay defines `window.__ngDevtoolsComponentOf` on the page. It takes an element and returns the id of the nearest component host, through shadow roots, or `null`. diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index b6401ed..d3e0b66 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -144,7 +144,7 @@ pnpm commit:check # Commit messages on your branch `pnpm typecheck` runs `ngc` on `app/tsconfig.json` and `examples/analog/tsconfig.app.json`, so template errors fail it. `app/tsconfig.json` turns on `strictTemplates`. -`pnpm test:panel` runs the tests in `app/src/__tests__` in jsdom, with the Analog Angular plugin compiling the components. `pnpm test:axe` builds the package, writes a static report of Angular Travel to `dist/panel-axe`, serves it, and runs axe on every tab and on each hub view (`?view=ngrx`, `analog`, `nativescript`, `capacitor`) in the dark color scheme (the panel is dark only). It fails on any violation or page error. Run `pnpm exec playwright install chromium` once before the first run. +`pnpm test:panel` runs the tests in `app/src/__tests__` in jsdom, with the Analog Angular plugin compiling the components. `pnpm test:axe` builds the package, writes a static report of Angular Travel to `dist/panel-axe`, serves it, and runs axe on every tab and on each hub view (`?view=ngrx`, `analog`, `nativescript`, `capacitor`) in both dark and light color schemes. It fails on any violation or page error. Run `pnpm exec playwright install chromium` once before the first run. `pnpm skills:check` validates the frontmatter of every skill and role and checks that the files and links they mention exist. `pnpm commit:check` checks every commit on your branch that is not on `main` (it compares with `upstream/main`, then `origin/main`, then `main`). diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index 5c78bf0..e8fd4eb 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -97,6 +97,10 @@ The overlay gives each page an id. The panel passes the id of the page it inspec When the inspected page navigates, the panel shows "Detecting Angular app…", looks for the server again and reconnects. +### Theme + +The panel follows the DevTools theme. If you switch DevTools between light and dark (**Settings** > **Preferences** > **Theme**), the panel switches with it. + ### Elements panel While the **Components** tab is open, select an element in the Chrome **Elements** panel. The Components tab selects the component that hosts that element (the element itself, or the nearest ancestor that is a component host). It expands the parent rows, clears the filter if it hides the row, and scrolls the row into view. On other tabs, the Elements selection does nothing. It also does nothing when `inspectors.components` is `false` in the [configuration](./configuration.md). diff --git a/apps/docs/src/content/getting-started/popup-and-hub.md b/apps/docs/src/content/getting-started/popup-and-hub.md index 3d46044..2ea5af3 100644 --- a/apps/docs/src/content/getting-started/popup-and-hub.md +++ b/apps/docs/src/content/getting-started/popup-and-hub.md @@ -102,6 +102,10 @@ In a search box that has text, Escape clears the box and leaves the p The panel saves its position, size and dock mode in `localStorage` under `ng-devtools-popup`, so it keeps its layout across reloads. Clear that key to reset it. +### Theme + +The panel follows the color scheme of the hub, which follows your system unless you pick light or dark in the hub. Without the hub, the panel follows your system. The popup frame around the panel switches with it and saves the last theme with the layout. + ## The hub ### Docks in the side rail diff --git a/docs/contributing/ui-guidelines.md b/docs/contributing/ui-guidelines.md index 3868563..5e3879c 100644 --- a/docs/contributing/ui-guidelines.md +++ b/docs/contributing/ui-guidelines.md @@ -1,10 +1,10 @@ # UI guidelines -The devtools panel (`app/`) is a dark-only, dense tool that people keep open next to their app. Every page should look like it belongs to the same product, work with the keyboard, and pass axe with WCAG AA contrast. +The devtools panel (`app/`) is a dense tool that people keep open next to their app. It supports dark and light themes and follows the browser DevTools color scheme by default. Every page should look like it belongs to the same product, work with the keyboard, and pass axe with WCAG AA contrast in both themes. ## Theme -The panel is dark only. A light theme is tracked in [#192](https://github.com/santoshyadavdev/angular-devtools/issues/192). +The panel supports dark and light. `data-theme` on `` picks the theme when the Chrome extension, the hub or a `?theme=` parameter sets it; otherwise the panel follows `prefers-color-scheme`. The palette lives in `app/src/styles/_palette.scss` and becomes CSS variables in `_theme.scss`. Change the brand in one place, `app/src/styles/main.scss`: @@ -14,7 +14,7 @@ The palette lives in `app/src/styles/_palette.scss` and becomes CSS variables in ); ``` -Accents available: `amber` (default), `ember`, `gold`. Add one by extending `$accents` in `_palette.scss`. +Accents available: `amber` (default), `ember`, `gold`. Add one by extending `$accents` in `_palette.scss`, with a `light` map (`base`, `hover`, `ink`) whose `base` passes 4.5:1 on `--surface-3` in the light theme. Light neutrals and status colors are `$neutrals-light` and `$status-light`. Always use the variables, never hex values: @@ -31,11 +31,11 @@ Always use the variables, never hex values: | `--control-h` | 34px, the height of every input, select and button | | `--ease`, `--font-mono` | Motion curve, code and tokens | -Brand colors belong only to the brand they represent: NgRx keeps its purple on the NgRx view, the Angular view uses the Angular gradient for its logo and title, and Analog, NativeScript and Capacitor use their own colors on their pages. Everything else is amber. +Brand colors belong only to the brand they represent: NgRx keeps its purple on the NgRx view, the Angular view uses the Angular gradient for its logo and title, and Analog, NativeScript and Capacitor use their own colors on their pages. Each one has a darker light-theme value (`VIEW_ACCENT_LIGHT` in `app/src/app.ts`). Everything else is amber. ## Shared building blocks -- **SCSS mixins** in `app/src/styles/_mixins.scss`, used with `@use 'mixins' as m;` in component styles: `m.focus-ring($offset)`, `m.field-focus`, `m.panel($level)`, `m.label`, `m.soft($color)`, `m.truncate`, `m.enter($duration)`. +- **SCSS mixins** in `app/src/styles/_mixins.scss`, used with `@use 'mixins' as m;` in component styles: `m.focus-ring($offset)`, `m.field-focus`, `m.panel($level)`, `m.label`, `m.soft($color)`, `m.truncate`, `m.enter($duration)`, and `m.light` for a light-theme override of a page-only color (it covers both `data-theme='light'` and the system preference). - **Dropdown**: `app/src/ui/select.ts` (``). Never use a native `