From 59fd4288a92bf7deb0a452ea64047342215dd99c Mon Sep 17 00:00:00 2001 From: Kam Date: Mon, 28 Sep 2026 18:12:38 +0300 Subject: [PATCH] feat(hub): move the devtools to @devframes/hub with live data and a refreshed UI The devtools ran as a single panel with data mostly from source scans, and every inspector looked a little different. Move them into @devframes/hub with a dock per tool (Angular, NgRx, Analog, plus NativeScript and Capacitor as coming soon), give every inspector live data from the running app, and restyle the whole panel around one theme. - Hub: one shared frame follows the rail, the amber launcher opens the whole hub, WebSocket transport, and hub connections stay local - Components: live tree, one row per instance, with inputs, outputs, listeners and injected services - Injectors: real injectors only, the environment chain, the lookup path, and which injector supplied each dependency - Signals: component picker, linkedSignal values, history kept across components - NgRx: @ngrx/signals and classic stores found through the environment injectors, with a change log, diffs and restore - Forms, Router, SSR & HTTP, Analog, Pipes, Dashboard: stable ids, per-tab data with expiry, correct counts, fewer false findings, lighter pushes - UI: SCSS theme with a one-line accent change, shared mixins, a custom accessible dropdown, consistent toolbars and states; axe clean in dark and light - Demo: rebuilt as Angular Travel with destinations, a signal store, resolvers and guards, a Signal Forms booking flow and a restyled DevTools Lab --- README.md | 69 +- app/index.html | 24 +- app/src/app.ts | 828 +- app/src/hub-dock-sync.ts | 62 + app/src/hub-rail-style.ts | 45 + app/src/page-id.ts | 12 + app/src/pages/analog-inspector.ts | 1040 +- app/src/pages/coming-soon.ts | 483 + app/src/pages/component-tree.ts | 1535 +- app/src/pages/dashboard.ts | 634 +- app/src/pages/di-inspector.ts | 1687 ++- app/src/pages/forms-field-detail.ts | 87 +- app/src/pages/forms-inspector.ts | 1002 +- app/src/pages/forms-report.ts | 190 +- app/src/pages/forms-timeline.ts | 362 +- app/src/pages/forms-types.ts | 214 +- app/src/pages/live-route.ts | 244 +- app/src/pages/network-inspector.ts | 1158 +- app/src/pages/pipes-inspector.ts | 1259 +- app/src/pages/route-current.ts | 156 +- app/src/pages/route-inspector.ts | 326 +- app/src/pages/route-lint.ts | 181 +- app/src/pages/route-setup.ts | 88 +- app/src/pages/route-timeline.ts | 286 +- app/src/pages/route-tree.ts | 295 +- app/src/pages/router-types.ts | 299 +- app/src/pages/signal-inspector.ts | 964 +- app/src/pages/store-inspector.ts | 1516 +- app/src/pages/store-types.ts | 144 + app/src/pages/tab-icon.ts | 81 + app/src/styles/_base.scss | 95 + app/src/styles/_mixins.scss | 45 + app/src/styles/_palette.scss | 42 + app/src/styles/_theme.scss | 35 + app/src/styles/main.scss | 4 + app/src/ui/select.ts | 414 + app/vite.config.ts | 10 +- examples/analog/src/styles.css | 26 +- extension/panel-bridge.js | 2 +- ...=> browser-agent-rpc-BXhoSh1z-CL6E-yfw.js} | 2 +- extension/ui/assets/index-BrPFmvaU.css | 1 + extension/ui/assets/index-CxXtTtuJ.js | 11939 ++++++++++++++++ extension/ui/assets/index-TNlU-6c2.js | 3543 ----- extension/ui/index.html | 7 +- package.json | 1 + packages/ng-devtools/package.json | 18 +- .../src/__tests__/agent-tools.test.ts | 160 +- .../src/__tests__/analog-mcp.test.ts | 99 +- .../src/__tests__/analog-runtime.test.ts | 23 +- .../src/__tests__/analog-server-log.test.ts | 48 +- .../src/__tests__/component-tree.test.ts | 313 + .../src/__tests__/forms-audit.test.ts | 368 + .../src/__tests__/forms-read.test.ts | 37 +- .../src/__tests__/forms-source.test.ts | 35 + .../src/__tests__/http-server.test.ts | 79 + .../ng-devtools/src/__tests__/http.test.ts | 256 +- .../ng-devtools/src/__tests__/hub.test.ts | 58 + .../src/__tests__/injector-tree.test.ts | 201 + .../src/__tests__/ngrx-collector.test.ts | 562 + .../__tests__/overlay-signal-target.test.ts | 48 - .../src/__tests__/overlay-tree.test.ts | 226 - .../src/__tests__/pipes-collector.test.ts | 165 +- .../src/__tests__/pipes-pages.test.ts | 52 +- .../src/__tests__/pipes-runtime.test.ts | 106 +- .../ng-devtools/src/__tests__/popup.test.ts | 46 +- .../src/__tests__/router-audit.test.ts | 215 + .../src/__tests__/router-extras.test.ts | 24 +- .../__tests__/router-guard-results.test.ts | 162 + .../src/__tests__/router-mcp.test.ts | 4 +- .../src/__tests__/serialize.test.ts | 93 + .../src/__tests__/signal-graph.test.ts | 220 + .../src/__tests__/signal-history.test.ts | 50 +- .../src/__tests__/vite-upgrade-guard.test.ts | 164 + packages/ng-devtools/src/analog-runtime.ts | 69 +- packages/ng-devtools/src/analog-server-log.ts | 59 +- packages/ng-devtools/src/component-tree.ts | 239 + packages/ng-devtools/src/devframe.ts | 360 +- packages/ng-devtools/src/element-id.ts | 30 + packages/ng-devtools/src/forms-actions.ts | 98 +- packages/ng-devtools/src/forms-collector.ts | 67 +- packages/ng-devtools/src/forms-dom.ts | 21 +- packages/ng-devtools/src/forms-read.ts | 108 +- packages/ng-devtools/src/forms.ts | 282 +- packages/ng-devtools/src/http-hydration.ts | 65 + packages/ng-devtools/src/http-overlay.ts | 64 +- packages/ng-devtools/src/http-payload.ts | 50 +- packages/ng-devtools/src/http-rules.ts | 1 + packages/ng-devtools/src/http.ts | 111 +- packages/ng-devtools/src/hub-docks.ts | 47 + packages/ng-devtools/src/hub.ts | 52 + packages/ng-devtools/src/injector-tree.ts | 344 + packages/ng-devtools/src/ngrx-collector.ts | 629 + packages/ng-devtools/src/ngrx-overlay.ts | 98 + packages/ng-devtools/src/ngrx-register.ts | 13 + packages/ng-devtools/src/ngrx-shared.ts | 298 + packages/ng-devtools/src/overlay.ts | 668 +- packages/ng-devtools/src/pipes-collector.ts | 173 +- packages/ng-devtools/src/pipes-runtime.ts | 148 +- packages/ng-devtools/src/popup.ts | 82 +- packages/ng-devtools/src/router-actions.ts | 180 +- packages/ng-devtools/src/router-links.ts | 33 +- packages/ng-devtools/src/router.ts | 2 +- .../src/rpc/__tests__/build-meta.test.ts | 41 + .../src/rpc/__tests__/get-components.test.ts | 18 + .../src/rpc/__tests__/get-ngrx-store.test.ts | 56 + .../src/rpc/__tests__/get-providers.test.ts | 56 + .../src/rpc/__tests__/get-routes.test.ts | 136 +- .../src/rpc/__tests__/get-signals.test.ts | 18 + .../ng-devtools/src/rpc/analog-register.ts | 68 +- packages/ng-devtools/src/rpc/analog-tools.ts | 69 +- packages/ng-devtools/src/rpc/build-meta.ts | 43 +- .../ng-devtools/src/rpc/component-tools.ts | 65 + packages/ng-devtools/src/rpc/forms-lint.ts | 2 +- packages/ng-devtools/src/rpc/forms-source.ts | 115 +- packages/ng-devtools/src/rpc/forms-tools.ts | 11 + .../ng-devtools/src/rpc/get-components.ts | 20 +- .../ng-devtools/src/rpc/get-ngrx-store.ts | 187 +- packages/ng-devtools/src/rpc/get-providers.ts | 105 +- packages/ng-devtools/src/rpc/get-routes.ts | 353 +- packages/ng-devtools/src/rpc/get-signals.ts | 10 +- packages/ng-devtools/src/rpc/ngrx-tools.ts | 96 + packages/ng-devtools/src/rpc/pipes-tools.ts | 49 +- .../src/rpc/router-config-tools.ts | 10 +- packages/ng-devtools/src/rpc/router-tools.ts | 7 + packages/ng-devtools/src/serialize.ts | 149 + packages/ng-devtools/src/signal-graph.ts | 191 + packages/ng-devtools/src/signal-history.ts | 42 +- packages/ng-devtools/src/types.ts | 95 +- packages/ng-devtools/src/vite.ts | 140 +- packages/ng-devtools/tsdown.config.ts | 9 +- pnpm-lock.yaml | 154 +- pnpm-workspace.yaml | 5 + public/destinations/CREDITS.md | 12 + public/destinations/bali.webp | Bin 0 -> 442842 bytes public/destinations/cape-town.webp | Bin 0 -> 250370 bytes public/destinations/iceland.webp | Bin 0 -> 165658 bytes public/destinations/istanbul.webp | Bin 0 -> 363344 bytes public/destinations/kyoto.webp | Bin 0 -> 294776 bytes public/destinations/lisbon.webp | Bin 0 -> 336594 bytes public/destinations/santorini.webp | Bin 0 -> 172682 bytes public/destinations/swiss-alps.webp | Bin 0 -> 218960 bytes src/app/app.config.ts | 4 +- src/app/app.css | 239 +- src/app/app.html | 56 +- src/app/app.routes.server.ts | 9 +- src/app/app.routes.ts | 45 +- src/app/app.spec.ts | 6 +- src/app/app.ts | 15 +- src/app/examples/example-page.ts | 47 +- src/app/examples/example-panel.ts | 16 +- src/app/examples/examples-overview.ts | 75 +- src/app/examples/examples.ts | 58 +- src/app/examples/forms-example.css | 117 +- src/app/examples/http-example.ts | 254 +- src/app/examples/stat-card.ts | 82 +- src/app/pages/about.ts | 177 +- src/app/pages/booking.ts | 711 + src/app/pages/destination-detail.ts | 361 + src/app/pages/destinations.ts | 358 + src/app/pages/home.ts | 301 +- src/app/pages/sign-in.ts | 210 + src/app/pages/trips.ts | 286 + src/app/products/product-detail.ts | 109 - src/app/products/product-list.ts | 153 - src/app/products/product.store.ts | 76 - src/app/products/product.ts | 59 - src/app/theme-toggle.ts | 70 +- src/app/travel/auth.ts | 24 + src/app/travel/destination-card.ts | 281 + src/app/travel/destination.ts | 179 + src/app/travel/travel.store.ts | 110 + src/app/travel/trip-routes.ts | 26 + src/index.html | 29 +- src/main.ts | 7 +- src/server.ts | 8 +- src/styles.css | 169 +- 176 files changed, 38007 insertions(+), 8712 deletions(-) create mode 100644 app/src/hub-dock-sync.ts create mode 100644 app/src/hub-rail-style.ts create mode 100644 app/src/page-id.ts create mode 100644 app/src/pages/coming-soon.ts create mode 100644 app/src/pages/store-types.ts create mode 100644 app/src/pages/tab-icon.ts create mode 100644 app/src/styles/_base.scss create mode 100644 app/src/styles/_mixins.scss create mode 100644 app/src/styles/_palette.scss create mode 100644 app/src/styles/_theme.scss create mode 100644 app/src/styles/main.scss create mode 100644 app/src/ui/select.ts rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-D1NJaqR8.js => browser-agent-rpc-BXhoSh1z-CL6E-yfw.js} (93%) create mode 100644 extension/ui/assets/index-BrPFmvaU.css create mode 100644 extension/ui/assets/index-CxXtTtuJ.js delete mode 100644 extension/ui/assets/index-TNlU-6c2.js create mode 100644 packages/ng-devtools/src/__tests__/component-tree.test.ts create mode 100644 packages/ng-devtools/src/__tests__/forms-audit.test.ts create mode 100644 packages/ng-devtools/src/__tests__/http-server.test.ts create mode 100644 packages/ng-devtools/src/__tests__/hub.test.ts create mode 100644 packages/ng-devtools/src/__tests__/injector-tree.test.ts create mode 100644 packages/ng-devtools/src/__tests__/ngrx-collector.test.ts delete mode 100644 packages/ng-devtools/src/__tests__/overlay-signal-target.test.ts delete mode 100644 packages/ng-devtools/src/__tests__/overlay-tree.test.ts create mode 100644 packages/ng-devtools/src/__tests__/router-audit.test.ts create mode 100644 packages/ng-devtools/src/__tests__/router-guard-results.test.ts create mode 100644 packages/ng-devtools/src/__tests__/serialize.test.ts create mode 100644 packages/ng-devtools/src/__tests__/signal-graph.test.ts create mode 100644 packages/ng-devtools/src/__tests__/vite-upgrade-guard.test.ts create mode 100644 packages/ng-devtools/src/component-tree.ts create mode 100644 packages/ng-devtools/src/element-id.ts create mode 100644 packages/ng-devtools/src/http-hydration.ts create mode 100644 packages/ng-devtools/src/hub-docks.ts create mode 100644 packages/ng-devtools/src/hub.ts create mode 100644 packages/ng-devtools/src/injector-tree.ts create mode 100644 packages/ng-devtools/src/ngrx-collector.ts create mode 100644 packages/ng-devtools/src/ngrx-overlay.ts create mode 100644 packages/ng-devtools/src/ngrx-register.ts create mode 100644 packages/ng-devtools/src/ngrx-shared.ts create mode 100644 packages/ng-devtools/src/rpc/__tests__/build-meta.test.ts create mode 100644 packages/ng-devtools/src/rpc/component-tools.ts create mode 100644 packages/ng-devtools/src/rpc/ngrx-tools.ts create mode 100644 packages/ng-devtools/src/serialize.ts create mode 100644 packages/ng-devtools/src/signal-graph.ts create mode 100644 public/destinations/CREDITS.md create mode 100644 public/destinations/bali.webp create mode 100644 public/destinations/cape-town.webp create mode 100644 public/destinations/iceland.webp create mode 100644 public/destinations/istanbul.webp create mode 100644 public/destinations/kyoto.webp create mode 100644 public/destinations/lisbon.webp create mode 100644 public/destinations/santorini.webp create mode 100644 public/destinations/swiss-alps.webp create mode 100644 src/app/pages/booking.ts create mode 100644 src/app/pages/destination-detail.ts create mode 100644 src/app/pages/destinations.ts create mode 100644 src/app/pages/sign-in.ts create mode 100644 src/app/pages/trips.ts delete mode 100644 src/app/products/product-detail.ts delete mode 100644 src/app/products/product-list.ts delete mode 100644 src/app/products/product.store.ts delete mode 100644 src/app/products/product.ts create mode 100644 src/app/travel/auth.ts create mode 100644 src/app/travel/destination-card.ts create mode 100644 src/app/travel/destination.ts create mode 100644 src/app/travel/travel.store.ts create mode 100644 src/app/travel/trip-routes.ts diff --git a/README.md b/README.md index 7846848..de8669f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Inspect Angular component trees, signals, dependency injection, and routes — a - **Signal graph** — visualize signal, computed, linkedSignal, effect nodes and their dependency edges (Angular 19+) - **DI inspector** — browse the injector hierarchy (element and environment) with providers at each level (Angular 17+) - **Route inspector** — the live route, every navigation as a full story (who started it, redirects, per-phase timing, which guard or resolver decided it, errors explained), the live route config with URL testing, router setup, route lint, and actions to navigate, replay, probe and abort -- **NgRx Store inspector** — detect `@ngrx/store` (actions, reducers, effects, selectors) and `@ngrx/signals` (`signalStore`, `signalState`, `signalMethod`) patterns from source; live state & action log via Redux DevTools protocol +- **NgRx Store inspector**: live `@ngrx/signals` stores (state, computed, methods, the component fields that use them) with a change log, per-change diffs and state restore (call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, so restore also notifies `watchState` listeners), plus the `@ngrx/store` state and action log (time travel with `provideStoreDevtools()`); source scan of `signalStore` members, `signalState`, `signalMethod`, actions, reducers, effects, selectors and features - **Forms inspector** — every form on the page (Signal Forms, reactive and template-driven) with each field's value, status, touched/dirty state and readable errors, plus a timeline of recent changes; hover a field to highlight its input - **SSR & HTTP inspector** — the TransferState payload, a timeline of HTTP calls made during SSR and on the client, hydration stats and warnings, and fault injection (status, delay, mock JSON body) per URL pattern on the client, in SSR or both - **Build metadata** — Angular version, TypeScript version, SSR status @@ -29,18 +29,29 @@ MCP agent support (`@devframes/agentic`) is included. ### Embedded in an Angular app (Express SSR) -Add the devframe middleware to your Express server: +Mount the devtools hub in your Express server: ```ts // server.ts -import { initDevframe } from 'devframe/initiate'; -import ngDevtools from '@santoshyadavdev/ng-devtools/devframe'; +import { initNgDevtoolsHub } from '@santoshyadavdev/ng-devtools/hub'; -const devtools = initDevframe(ngDevtools, { base: '/__ng-devtools/' }); +const devtools = initNgDevtoolsHub({ ws: false }); app.use(devtools.nodeMiddleware); ``` -Open `http://localhost:4000/__ng-devtools/` to see the devtools UI. +Load the overlay in development (see [Browser Overlay](#browser-overlay)) and a floating button appears on your page. It opens the devtools with one dock entry per tool: + +| Dock entry | Shows | +| ------------ | ------------------------------------------------------------------------------ | +| Angular | Dashboard, components, routes, signals, injectors and forms | +| NgRx | Store patterns from source, and live state and actions | +| Analog | File routes, server calls, render modes and lint (a notice in non-Analog apps) | +| NativeScript | Coming soon | +| Capacitor | Coming soon | + +The full-page viewer is at `http://localhost:4000/__devframes/`. The hub is built on [`@devframes/hub`](https://github.com/devframes/devframe), so other devframe tools can join the same dock. + +To mount only the devtools panel without the dock, use `initDevframe(ngDevtools, { base: '/__ng-devtools/' })` from `devframe/initiate`, as before. To fill the SSR & HTTP tab, add the interceptor and hydration hooks to your app config: @@ -99,7 +110,7 @@ npx @santoshyadavdev/ng-devtools mcp } ``` -When embedded in Express, the MCP endpoint is also available over HTTP at `/__ng-devtools/__mcp`. +When embedded in Express, the MCP endpoint is also available over HTTP at `/__devframes/__mcp` (or `/__ng-devtools/__mcp` without the hub). #### Agent Tools @@ -212,23 +223,29 @@ bootstrapApplication(App, appConfig).then(() => { }); ``` -The panel is then at `/__ng-devtools/` on the Vite dev server, and the MCP endpoint at `/__ng-devtools/__mcp`. The Analog tab shows: +The floating button appears on the page, the full viewer is at `/__devframes/` on the Vite dev server, and the MCP endpoint at `/__devframes/__mcp`. + +The devtools only answer this machine, and only pages served from `localhost`, `127.0.0.1` or the Chrome extension, so another website open in your browser can't reach them. If you open the dev server through another hostname that points to your machine (for example `myapp.test`), list it in Vite's `server.allowedHosts` and the devtools trust it too. Other origins can be added with `ngDevtools({ allowedOrigins: ['https://tunnel.example'] })`. + +The Analog dock shows: - Routes: every page, layout and markdown file with its URL, route groups, `[param]` and catch-all segments, `.server.ts` files and routeMeta. Test a URL to see which files render it. -- Server: page renders (server rendered or client only), `load()` fetches, server functions and API calls with status, time and a redacted preview, plus a request playground for API routes. A `load()` that runs on the server and again in the browser is flagged. +- Server: page renders (server rendered or client only), `load()` fetches, server functions and API calls with status, time and a redacted preview, plus a request playground for API routes and a button to clear the list. A `load()` that runs while a page is server rendered and again in the browser right after it loads is flagged. - Render: SSR, prerendered or client only per page, from config, build output and the last request. - Content and Lint: markdown files, and checks for duplicate URLs, missing default exports, layouts without ``, orphan `.server.ts` files, API method suffixes, prerender entries and frontmatter. -The Analog tab appears only in Analog apps, and the Routes tab and Dashboard switch to Analog's file routes and SSR setting there. Tested with Analog 2.7 on Angular 20 (a fresh app from the official template, npm and pnpm) and Angular 22. The demo lives in `examples/analog` (`pnpm analog:dev`). +The Analog dock is always in the rail, but it shows Analog data only in Analog apps; in other apps it shows a "This app doesn’t use Analog" page. In Analog apps it is also a tab when the panel is mounted without the hub, and the Routes tab and Dashboard switch to Analog's file routes and SSR setting. Tested with Analog 2.7 on Angular 20 (a fresh app from the official template, npm and pnpm) and Angular 22. The demo lives in `examples/analog` (`pnpm analog:dev`). #### SSR & HTTP The SSR & HTTP tab needs `withNgDevtools()` and `provideNgDevtoolsHttp()` (see [Embedded in an Angular app](#embedded-in-an-angular-app-express-ssr)), and SSR and the devtools middleware must run in the same Express process. It works in development builds only; in production the interceptor passes requests through untouched. -- **HTTP timeline**: every `HttpClient` request, tagged SSR or Client, with method, URL, status, time, whether the transfer cache answered it, and whether a fault rule changed it. Click a row for a response preview. Client calls are grouped by page; pick the page at the top. SSR calls are kept for the whole server, not per page, until you press Clear timeline. -- **Fault injection**: add a rule with a URL pattern (a substring, or a glob where `*` matches anything, so `/api/*` matches both relative and absolute URLs), an optional method, where it applies (SSR + client, SSR only, client only), and a status, a delay (up to 10 s) and an optional JSON body. A status of 400 or more fails the request with an `HttpErrorResponse`; a lower status returns the body as a mocked response. Client rules apply right away; SSR rules apply from the next page load. -- **Hydration**: whether hydration is on, hydrated components and nodes, skipped components, incremental defer blocks, and the hydration warnings Angular logged. -- **TransferState payload**: each entry in the page's `ng-state` script with its size, with HTTP cache entries decoded to status, URL and body. +Register `withNgDevtools()` before your own interceptors (`provideHttpClient(withNgDevtools(), withInterceptors([auth]))`), so it records requests as the app makes them and fault rules apply before anything else. Transfer cache hits are detected when the cached response comes back right away, or when the page's TransferState holds a GET or HEAD entry for the same URL, so an async interceptor after it does not hide them. + +- **HTTP timeline**: every `HttpClient` request, tagged SSR or Client, with method, URL, the page that made it, status, time, whether the transfer cache answered it, and whether a fault rule changed it. Click a row for a response preview. Pick the page at the top; the timeline shows its client calls and the SSR calls made while rendering its first URL. The picker stays on the page you picked until that tab closes. Calls are kept until you press Clear timeline. +- **Fault injection**: add a rule with a URL pattern (a substring, or a glob where `*` matches anything, so `/api/*` matches both relative and absolute URLs), an optional method, where it applies (SSR + client, SSR only, client only), and a status, a delay (up to 10 s) and an optional JSON body. A status of 400 or more fails the request with an `HttpErrorResponse`; a lower status returns the body as a mocked response (a `responseType: 'text'` request gets the body as text). A rule with only a delay passes the request through. Client rules apply right away; SSR rules apply from the next page load. SSR mocks are not written to TransferState, so the browser requests the URL again; apply the rule on SSR + client to mock both. +- **Hydration**: whether hydration is on (the server sent hydration annotations), hydrated components and nodes, skipped components, incremental defer blocks, mismatched components with the expected and actual DOM, and the hydration warnings (NG05xx) Angular logged. Warnings are captured only with `provideNgDevtoolsHttp()`. +- **TransferState payload**: each entry in the page's `{APP_ID}-state` script with its size, with HttpClient and Analog cache entries decoded to status, URL and body, and `__nghData__` / `__nghDeferData__` labelled as hydration annotations. Routes that are prerendered at build time make no requests at runtime and ignore SSR rules. Use `RenderMode.Server` in `app.routes.server.ts` for pages you want to test this way. @@ -241,7 +258,7 @@ Response previews and TransferState values are not redacted: they are sent to th | `ng-devtools:component-tree` | Live component hierarchy | | `ng-devtools:signal-graph` | Signal dependency graph | | `ng-devtools:injector-tree` | DI injector hierarchy | -| `ng-devtools:ngrx-store` | Live NgRx state & action log | +| `ng-devtools:ngrx-store` | Live NgRx stores & change log | | `ng-devtools:forms` | Live forms and recent changes | | `ng-devtools:router` | Live route and navigations | @@ -277,8 +294,10 @@ The overlay runs inside the user's Angular page and collects live component, sig import '@santoshyadavdev/ng-devtools/overlay'; ``` -It looks for the devframe connection next to the page and then at -`/__ng-devtools/`. +It looks for the devframe connection next to the page, then at +`/__ng-devtools/` and `/__devframes/ng-devtools/`. It also adds the floating +button below; with the hub mounted, the button opens the whole hub (every dock +in a side rail). `initOverlay` is exported for a devtools mounted somewhere else. Importing the module has already started an overlay on the default URLs by then, so dispose of @@ -301,18 +320,20 @@ import { createDevtoolsPopup } from '@santoshyadavdev/ng-devtools/popup'; createDevtoolsPopup(); ``` -This adds a purple FAB button (bottom-right) that opens the full devtools UI in an iframe. Supports three dock modes (float, bottom, right), dragging, resizing, and persists position via localStorage. The popup is automatically loaded in development when using the demo app. +This adds a floating button (bottom-right) that opens the full devtools UI in an iframe. Supports three dock modes (float, bottom, right), dragging, resizing, and persists position via localStorage. The popup is automatically loaded in development when using the demo app. ## Demo App -The repository includes a demo Angular app (`src/`) that showcases the devtools with a product catalog built using `@ngrx/signals`: +The repository includes a demo app, **Angular Travel** (`src/`), that looks and behaves like a real booking site so every inspector has something to show: -- **Home** — simple counter with `signal()` -- **Products** — product list and detail pages powered by a `signalStore` with `withState`, `withComputed`, and `withMethods` -- **About** — static page -- **Examples → SSR & HTTP** (`/examples/http`) — a product list fetched from `/api/products` during SSR and replayed from the transfer cache. The endpoint accepts `?delay=` and `?fail=` for backend scenarios; run the SSR server (`pnpm build --configuration development && node dist/angular-devtools/server/server.mjs`) to see server calls +- **Destinations**: search, region filter and sort kept in the URL, backed by an `@ngrx/signals` store (`withState`, `withComputed`, `withMethods`) +- **Trip pages**: loaded by a resolver that redirects unknown trips, with a route title resolver +- **Booking**: a Signal Forms checkout with a departure date rule, a seat limit and an unsaved-changes guard +- **My Trips**: behind a sign-in guard that redirects to a reactive form and back +- **DevTools Lab** (`/examples`): small, focused pages for signals, components, DI, routes and all three form APIs +- **SSR & HTTP** (`/examples/http`): a product list fetched from `/api/products` during SSR and replayed from the transfer cache. The endpoint accepts `?delay=` and `?fail=` for backend scenarios; run the SSR server (`pnpm build --configuration development && node dist/angular-devtools/server/server.mjs`) to see server calls -Run `pnpm start` and click the purple FAB button to open the devtools popup and see all inspectors in action. +Run `pnpm start` and click the amber button in the corner to open the devtools. Destination photos are from Unsplash, credited in `public/destinations/CREDITS.md`. ## Development diff --git a/app/index.html b/app/index.html index 2386b61..c9229c7 100644 --- a/app/index.html +++ b/app/index.html @@ -3,27 +3,15 @@ - Angular DevTools + + + Angular DevTools + diff --git a/app/src/app.ts b/app/src/app.ts index 052034f..90914b5 100644 --- a/app/src/app.ts +++ b/app/src/app.ts @@ -1,4 +1,18 @@ -import { Component, computed, signal, OnInit, OnDestroy } from '@angular/core'; +import { + Component, + ElementRef, + Injector, + afterNextRender, + afterRenderEffect, + computed, + inject, + linkedSignal, + signal, + untracked, + viewChild, + OnInit, + OnDestroy, +} from '@angular/core'; import { connectDevframe, type DevframeRpcClient } from 'devframe/client'; import { Dashboard } from './pages/dashboard'; import { ComponentTree } from './pages/component-tree'; @@ -11,9 +25,91 @@ import { PipesInspector } from './pages/pipes-inspector'; import type { Tab, Tabs } from './types/tab.types'; import { AnalogInspector } from './pages/analog-inspector'; 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 { followHubDocks, selectHubDock } from './hub-dock-sync'; + +const HUB_VIEWS = ['angular', 'ngrx', 'analog', 'nativescript', 'capacitor'] as const; + +type View = (typeof HUB_VIEWS)[number]; + +const VIEW_TITLE: Record = { + angular: 'Angular', + ngrx: 'NgRx Store', + analog: 'Analog', + nativescript: 'NativeScript', + capacitor: 'Capacitor', +}; + +const VIEW_ACCENT: Partial> = { + ngrx: '#d770e8', + analog: '#ff5470', + nativescript: '#8196ff', + capacitor: '#53b9ff', +}; + +const VIEW_TAB: Partial> = { ngrx: 'store', analog: 'analog' }; +const TAB_VIEW: Partial> = { store: 'ngrx', analog: 'analog' }; + +const COMING_SOON: Partial> = { + nativescript: { + id: 'nativescript', + name: 'NativeScript', + badge: 'Coming Soon', + heading: 'NativeScript Support', + summary: + 'Inspect NativeScript Angular apps running natively on iOS and Android, with the same tools.', + color: '#3c5afd', + plans: [ + 'Native overlay that reports the component tree from the device', + 'Standalone devtools server your phone or simulator connects to', + 'Example NativeScript app to try it end to end', + ], + pr: 16, + author: { name: 'Nathan Walker', login: 'NathanWalker' }, + }, + capacitor: { + id: 'capacitor', + name: 'Capacitor', + badge: 'Coming Soon', + heading: 'Capacitor Support', + summary: 'Connect Ionic and Capacitor apps running in a device WebView back to these tools.', + color: '#119eff', + plans: [ + 'Manual overlay start for apps that load from capacitor:// or a device', + 'Connection settings passed in, no cross-origin probing', + 'Setup guides for the Android emulator and iOS simulator', + ], + pr: 21, + author: { name: 'Erkam Yaman', login: 'erkamyaman' }, + }, +}; + +const NOT_ANALOG: ComingSoonInfo = { + id: 'analog', + name: 'Analog', + badge: 'Not in this app', + heading: 'This app doesn’t use Analog', + summary: + 'In an Analog app this dock shows your file routes, server load() and API calls, render modes and route lint.', + color: '#dd0330', + plans: [ + 'Which page, layout and .server.ts files render a URL', + 'Every load(), server function and API call with timing', + 'SSR, prerendered or client only, per page', + ], + link: { label: 'Get started with Analog', href: 'https://analogjs.org/docs/getting-started' }, +}; + +function readView(): View | null { + const view = new URLSearchParams(location.search).get('view'); + return HUB_VIEWS.find((known) => known === view) ?? null; +} @Component({ selector: 'app-root', + host: { '[style.--accent]': 'viewAccent()' }, imports: [ Dashboard, ComponentTree, @@ -25,158 +121,469 @@ import { NetworkInspector } from './pages/network-inspector'; PipesInspector, AnalogInspector, NetworkInspector, + ComingSoon, + TabIcon, ], template: `

- - - Angular DevTools + + + } + + {{ title() }}

-
-
- @switch (tab()) { - @case ('dashboard') { - - } - @case ('components') { - - } - @case ('routes') { - - } - @case ('signals') { - - } - @case ('injectors') { - - } - @case ('store') { - - } - @case ('network') { - - } - @case ('forms') { - - } - @case ('pipes') { - - } - @case ('analog') { - +
+ @if (connectionFailed()) { + + } @else if (comingSoon(); as info) { + + } @else { + @switch (tab()) { + @case ('dashboard') { + + } + @case ('components') { + + } + @case ('routes') { + + } + @case ('signals') { + + } + @case ('injectors') { + + } + @case ('store') { + + } + @case ('network') { + + } + @case ('forms') { + + } + @case ('pipes') { + + } + @case ('analog') { + + } } }
`, styles: ` + @use 'mixins' as m; + :host { + --accent-soft: color-mix(in srgb, var(--accent) 12%, transparent); + --accent-line: color-mix(in srgb, var(--accent) 45%, transparent); display: flex; flex-direction: column; height: 100vh; + height: 100dvh; + min-width: 0; + background: + radial-gradient( + 900px 300px at 10% -10%, + color-mix(in srgb, var(--accent) 7%, transparent), + transparent 70% + ), + var(--bg); } header { + position: sticky; + top: 0; + z-index: 10; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + flex-shrink: 0; align-items: center; - gap: 16px; - padding: 8px 16px; - background: #18181b; - border-bottom: 1px solid #27272a; + gap: 8px 16px; + height: 45px; + padding: 0 16px; + background: color-mix(in srgb, var(--surface) 82%, transparent); + backdrop-filter: blur(12px) saturate(1.3); + border-bottom: 1px solid var(--border); } .brand { - margin: 0; - font-size: inherit; display: flex; + flex-shrink: 0; align-items: center; - gap: 8px; - font-weight: 600; - color: var(--accent); - } - .brand span { + gap: 10px; + min-width: 0; + margin: 0; color: var(--accent); + font-size: 15px; + font-weight: 650; + line-height: 1.2; + letter-spacing: -0.01em; white-space: nowrap; } + .title { + overflow: hidden; + text-overflow: ellipsis; + } + .mark { + display: grid; + flex-shrink: 0; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 8%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); + } + .mark.ng-mark { + background: linear-gradient( + 135deg, + color-mix(in srgb, #f60a48 12%, transparent), + color-mix(in srgb, #9717e7 12%, transparent) + ); + box-shadow: inset 0 0 0 1px color-mix(in srgb, #dc087d 34%, transparent); + } + .ng-text { + background: linear-gradient(90deg, #ff4d75 0%, #f23d9a 45%, #c06bf5 80%, #a98bff 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } nav { + position: relative; display: flex; - flex-wrap: wrap; - gap: 4px; - flex: 1; + flex: 0 1 auto; + flex-wrap: nowrap; + gap: 2px; min-width: 0; + height: 34px; + padding: 2px; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scroll-padding-inline: 24px; + scrollbar-width: none; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); } - @media (max-width: 640px) { - nav { - order: 3; - flex-basis: 100%; - } + nav::-webkit-scrollbar { + display: none; + } + nav.fade-start { + mask-image: linear-gradient(90deg, transparent, #000 28px); + } + nav.fade-end { + mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); + } + nav.fade-start.fade-end { + mask-image: linear-gradient( + 90deg, + transparent, + #000 28px, + #000 calc(100% - 28px), + transparent + ); + } + nav:empty { + display: none; } nav button { - padding: 6px 14px; + position: relative; + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; border: none; - border-radius: 6px; + border-radius: 8px; background: transparent; - color: #a1a1aa; - cursor: pointer; + color: var(--text-2); + font: inherit; font-size: 13px; - transition: all 0.15s; + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + background-color 0.16s var(--ease), + color 0.16s var(--ease), + box-shadow 0.16s var(--ease), + transform 0.12s var(--ease); + } + nav button app-tab-icon { + color: var(--text-3); + transition: color 0.16s var(--ease); } nav button:hover { - background: #27272a; - color: #e4e4e7; + background: var(--surface-2); + color: var(--text); + } + nav button:hover app-tab-icon { + color: var(--text-2); + } + nav button:active { + transform: scale(0.97); } nav button.active { - background: #3f3f46; - color: #fff; + background: var(--surface-3); + color: var(--text-strong); + box-shadow: + inset 0 0 0 1px var(--border-strong), + 0 4px 14px -8px rgba(0, 0, 0, 0.9); + } + nav button.active app-tab-icon { + color: var(--accent); + } + nav button:focus-visible { + @include m.focus-ring(-2px); } .status { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + height: 24px; margin-left: auto; - font-size: 12px; - padding: 3px 10px; - border-radius: 99px; - background: #44403c; - color: #a8a29e; + padding: 0 10px 0 6px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text-2); + font-size: 11px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + transition: + color 0.2s var(--ease), + border-color 0.2s var(--ease), + background-color 0.2s var(--ease); + } + .dot { + position: relative; + display: grid; + place-items: center; + width: 12px; + height: 12px; + } + .dot span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warn); + animation: blink 1.2s ease-in-out infinite; + } + .dot::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: var(--warn); + opacity: 0; } .status.connected { - background: #14532d; - color: #4ade80; + border-color: color-mix(in srgb, var(--ok) 28%, transparent); + background: color-mix(in srgb, var(--ok) 8%, transparent); + color: var(--text); + } + .status.connected .dot span { + background: var(--ok); + box-shadow: 0 0 6px var(--ok); + animation: none; + } + .status.failed { + border-color: color-mix(in srgb, var(--danger) 36%, transparent); + color: var(--text); + } + .status.failed .dot span { + background: var(--danger); + animation: none; + } + .status.connected .dot::before { + background: var(--ok); + animation: ping 2s var(--ease) infinite; + } + @keyframes blink { + 50% { + opacity: 0.3; + } + } + @keyframes ping { + 0% { + transform: scale(0.5); + opacity: 0.55; + } + 80%, + 100% { + transform: scale(1.6); + opacity: 0; + } } main { flex: 1; + min-height: 0; overflow: auto; - padding: 16px; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding: 20px; + } + main:focus { + outline: none; + } + main > * { + animation: enter 0.28s var(--ease) both; + } + .connection-error { + max-width: 60ch; + margin: 0; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--danger) 36%, transparent); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + } + @media (max-width: 720px) { + header { + flex-wrap: wrap; + height: auto; + padding: 8px 16px; + } + nav { + order: 3; + flex: 1 1 100%; + } + } + @media (max-width: 560px) { + header { + padding-inline: 12px; + } + main { + padding: 16px 12px; + } + } + @media (max-width: 400px) { + .brand { + flex-shrink: 1; + gap: 8px; + } } `, }) @@ -194,45 +601,143 @@ export class App implements OnInit, OnDestroy { { id: 'pipes', label: 'Pipes' }, { id: 'network', label: 'SSR & HTTP' }, ]; - readonly tabs = computed(() => this.allTabs.filter((t) => t.id !== 'analog' || this.analog())); + readonly view = signal(readView()); + readonly viewAccent = computed(() => { + const view = this.view(); + return (view && VIEW_ACCENT[view]) ?? null; + }); + readonly title = computed(() => { + const view = this.view(); + return view ? VIEW_TITLE[view] : 'Angular DevTools'; + }); + readonly analogKnown = signal(false); + readonly comingSoon = computed(() => { + const view = this.view(); + if (view === 'analog') return this.analogKnown() && !this.analog() ? NOT_ANALOG : undefined; + return view ? COMING_SOON[view] : undefined; + }); + readonly tabs = computed(() => { + if (this.comingSoon()) return []; + const view = this.view(); + const only = view ? VIEW_TAB[view] : undefined; + if (only) return this.allTabs.filter((t) => t.id === only); + return this.allTabs.filter( + (t) => (t.id !== 'analog' || this.analog()) && !(view === 'angular' && TAB_VIEW[t.id]), + ); + }); - tab = signal('dashboard'); + tab = linkedSignal(() => { + const view = this.view(); + return (view && VIEW_TAB[view]) || 'dashboard'; + }); rpc = signal(null); connected = signal(false); + readonly connectionFailed = signal(false); + + private stopFollowing = () => {}; + private readonly nav = viewChild>('nav'); + private readonly main = viewChild>('main'); + private readonly injector = inject(Injector); + private navObserver?: ResizeObserver; + readonly navFade = signal({ start: false, end: false }); + + constructor() { + afterNextRender(() => { + const nav = this.nav()?.nativeElement; + if (!nav || typeof ResizeObserver === 'undefined') return; + this.navObserver = new ResizeObserver(() => this.measureNav()); + this.navObserver.observe(nav); + }); + afterRenderEffect(() => { + this.tab(); + this.tabs(); + untracked(() => { + this.revealActiveTab(); + this.measureNav(); + }); + }); + } + + measureNav() { + const nav = this.nav()?.nativeElement; + if (!nav) return; + const max = nav.scrollWidth - nav.clientWidth; + const start = nav.scrollLeft > 1; + const end = max > 1 && nav.scrollLeft < max - 1; + const current = this.navFade(); + if (current.start !== start || current.end !== end) this.navFade.set({ start, end }); + } + + private revealActiveTab() { + const nav = this.nav()?.nativeElement; + const active = nav?.querySelector('button.active'); + if (!nav || !active || nav.scrollWidth <= nav.clientWidth) return; + const inset = 24; + const left = active.offsetLeft - inset; + const right = active.offsetLeft + active.offsetWidth + inset - nav.clientWidth; + if (nav.scrollLeft > left) nav.scrollLeft = Math.max(0, left); + else if (nav.scrollLeft < right) nav.scrollLeft = right; + } ngOnInit() { + 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 + } // Deep link: read tab from hash const params = new URLSearchParams(location.hash.replace(/^#/, '')); const hashTab = params.get('tab'); - if (hashTab && this.allTabs.some((t) => t.id === hashTab)) { + if (hashTab && this.tabs().some((t) => t.id === hashTab)) { this.tab.set(hashTab as Tab); } const baseURL = detectBaseURL(); - connectDevframe(baseURL ? { baseURL } : {}).then((client) => { - this.rpc.set(client); - this.connected.set(true); - const scoped = client.scope('ng-devtools').rpc as unknown as { - call: (name: string) => Promise; - }; - scoped.call('analog-project').then( - (project) => { - const isAnalog = !!(project as { analog?: boolean } | null)?.analog; - this.analog.set(isAnalog); - if (!isAnalog && this.tab() === 'analog') this.tab.set('dashboard'); - }, - () => { - if (this.tab() === 'analog') this.tab.set('dashboard'); - }, - ); - client.events.on('connection:status', (status) => { - this.connected.set(status === 'connected'); - }); - }); + connectDevframe(baseURL ? { baseURL } : {}).then( + (client) => { + this.rpc.set(client); + this.connected.set(true); + const scoped = client.scope('ng-devtools').rpc as unknown as { + call: (name: string) => Promise; + }; + scoped.call('analog-project').then( + (project) => { + const isAnalog = !!(project as { analog?: boolean } | null)?.analog; + this.analog.set(isAnalog); + this.analogKnown.set(true); + if (!isAnalog && this.tab() === 'analog' && !this.view()) this.tab.set('dashboard'); + }, + () => { + this.analogKnown.set(true); + if (this.tab() === 'analog' && !this.view()) this.tab.set('dashboard'); + }, + ); + client.events.on('connection:status', (status) => { + this.connected.set(status === 'connected'); + }); + }, + () => { + this.connectionFailed.set(true); + this.analogKnown.set(true); + }, + ); } ngOnDestroy() { - // cleanup handled by devframe client + this.stopFollowing(); + this.navObserver?.disconnect(); + } + + private showView(view: View) { + if (view === this.view()) return; + this.view.set(view); + const url = new URL(location.href); + url.searchParams.set('view', view); + url.hash = ''; + history.replaceState(history.state, '', url); } formFocus = signal<{ id: string } | null>(null); @@ -243,9 +748,59 @@ export class App implements OnInit, OnDestroy { } switchTab(id: Tab) { - this.tab.set(id); + const target = TAB_VIEW[id]; + if (this.view() === 'angular' && target) { + void this.activateDock(target, id); + return; + } + this.setTab(id); history.replaceState(history.state, '', `#tab=${id}`); } + + private setTab(id: Tab) { + this.keepFocus(); + this.tab.set(id); + } + + private keepFocus() { + const active = document.activeElement; + const main = this.main()?.nativeElement; + if (active && active !== document.body && !main?.contains(active)) return; + afterNextRender( + () => { + const now = document.activeElement; + if (now && now !== document.body) return; + const target = + this.nav()?.nativeElement.querySelector('button.active') ?? + this.main()?.nativeElement; + target?.focus(); + }, + { injector: this.injector }, + ); + } + + private async activateDock(view: View, fallback: Tab) { + if (await selectHubDock(view)) return; + if (window.parent === window) { + this.keepFocus(); + this.showView(view); + return; + } + const client = this.rpc() as unknown as { + call?: (method: string, ...args: unknown[]) => Promise; + } | null; + if (!client?.call) { + this.setTab(fallback); + return; + } + try { + await client.call('hub:docks:activate', { dockId: `ng-devtools:${view}` }); + this.keepFocus(); + this.showView(view); + } catch { + this.setTab(fallback); + } + } } // Chrome extension passes ?baseURL=...; embedded uses /__ng-devtools/; standalone uses default @@ -257,6 +812,21 @@ function sameOrigin(value: string): boolean { } } +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1']; + +function loopbackFromExtension(value: string): boolean { + if (location.protocol !== 'chrome-extension:') return false; + try { + const url = new URL(value); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + LOOPBACK_HOSTS.includes(url.hostname) + ); + } catch { + return false; + } +} + function detectBaseURL(): string | undefined { const params = new URLSearchParams(location.search); const fromQuery = params.get('baseURL'); @@ -264,10 +834,12 @@ function detectBaseURL(): string | undefined { // the panel opens its RPC channel. // `new URL` throws on a malformed value, and this runs before the connection // is made, so an unhandled throw would leave the panel blank. - if (fromQuery && sameOrigin(fromQuery)) { + if (fromQuery && (sameOrigin(fromQuery) || loopbackFromExtension(fromQuery))) { return fromQuery; } - if (location.pathname.includes('__ng-devtools')) return undefined; + if (location.pathname.includes('__ng-devtools') || location.pathname.includes('__devframes/')) { + return undefined; + } return '/__ng-devtools/'; } diff --git a/app/src/hub-dock-sync.ts b/app/src/hub-dock-sync.ts new file mode 100644 index 0000000..cf65572 --- /dev/null +++ b/app/src/hub-dock-sync.ts @@ -0,0 +1,62 @@ +const CLIENT_CONTEXT_KEY = '__DEVFRAME_HUB_CLIENT_CONTEXT__'; +const DOCK_PREFIX = 'ng-devtools:'; + +interface DockState { + events: { on: (event: 'entry:activated', listener: () => void) => () => void }; +} + +interface HubClientContext { + docks: { + selectedId: string | null; + getStateById: (id: string) => DockState | undefined; + switchEntry: (id?: string | null) => Promise; + }; +} + +function hubContext(): HubClientContext | undefined { + try { + if (window.parent === window) return undefined; + return (window.parent as unknown as Record)[ + CLIENT_CONTEXT_KEY + ]; + } catch { + return undefined; + } +} + +export function followHubDocks( + views: readonly T[], + onView: (view: T) => void, + attempts = 50, +): () => void { + const ctx = hubContext(); + if (!ctx) { + if (attempts <= 0 || window.parent === window) return () => {}; + let stop = () => {}; + const timer = setTimeout(() => (stop = followHubDocks(views, onView, attempts - 1)), 100); + return () => { + clearTimeout(timer); + stop(); + }; + } + const offs = views.map( + (view) => + ctx.docks + .getStateById(DOCK_PREFIX + view) + ?.events.on('entry:activated', () => onView(view)) ?? (() => {}), + ); + const selected = ctx.docks.selectedId; + const current = views.find((view) => DOCK_PREFIX + view === selected); + if (current) onView(current); + return () => offs.forEach((off) => off()); +} + +export async function selectHubDock(view: string): Promise { + const ctx = hubContext(); + if (!ctx) return false; + try { + return await ctx.docks.switchEntry(DOCK_PREFIX + view); + } catch { + return false; + } +} diff --git a/app/src/hub-rail-style.ts b/app/src/hub-rail-style.ts new file mode 100644 index 0000000..c896d30 --- /dev/null +++ b/app/src/hub-rail-style.ts @@ -0,0 +1,45 @@ +const ACTIVE_DOCK_STYLE = ` + .devframes-dock-entry button { + transition: opacity 0.2s, filter 0.2s, background-color 0.2s, transform 0.3s; + } + .devframes-dock-entry button:not(.scale-120) { + opacity: 0.45; + filter: saturate(0); + } + .devframes-dock-entry button:not(.scale-120):hover, + .devframes-dock-entry button:not(.scale-120):focus-visible { + opacity: 1; + filter: none; + } + .devframes-dock-entry button:focus-visible { + outline: 2px solid #f5a524; + 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); + } + iframe { + background: #0b0b0e; + color-scheme: dark; + } + @media (prefers-reduced-motion: reduce) { + .devframes-dock-entry button { + transition: none; + } + } +`; + +export function styleHubRail(doc: Document | null | undefined, 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); + return; + } + if (attempts > 0) setTimeout(() => styleHubRail(doc, attempts - 1), 100); +} diff --git a/app/src/page-id.ts b/app/src/page-id.ts new file mode 100644 index 0000000..667f4f9 --- /dev/null +++ b/app/src/page-id.ts @@ -0,0 +1,12 @@ +const PAGE_ID_KEY = 'ng-devtools-page-id'; + +export function hostPageId(): string | null { + try { + const fromQuery = new URLSearchParams(location.search).get('pageId'); + if (fromQuery) return fromQuery; + if (window.top === window) return null; + return sessionStorage.getItem(PAGE_ID_KEY) || null; + } catch { + return null; + } +} diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index a114e71..2528770 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -9,6 +9,7 @@ import { untracked, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { Select } from '../ui/select'; interface AnalogRoute { id: string; @@ -70,9 +71,16 @@ interface AnalogPage { hydrationErrors: string[]; } +interface DuplicateLoad { + route: string; + ssrAt: number; + browserAt: number; +} + interface AnalogState { pages?: AnalogPage[]; calls?: AnalogCall[]; + duplicates?: DuplicateLoad[]; } interface Finding { @@ -248,12 +256,16 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @Component({ selector: 'app-analog-inspector', + imports: [Select], template: ` @if (project() === null) { -

Reading the project…

+
+ + Reading the project… +
} @else if (!project()!.analog) {
-

This app is not an Analog app.

+

This app is not an Analog app.

Add ngDevtools() from @santoshyadavdev/ng-devtools/vite next to analog() in vite.config.ts and run the Analog dev server. @@ -263,7 +275,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth

Analog - {{ project()!.version }} + {{ project()!.version || 'unknown' }}
Pages @@ -284,7 +296,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @if (page(); as p) {
Open in the browser - {{ p.url }} + {{ p.url }}
}
@@ -315,22 +327,22 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @switch (view()) { @case ('routes') {
-
+ - +
@if (m.matched) { - {{ testUrl() }} renders + {{ testUrl() }} renders
    @for (r of m.chain; track r.id) {
  1. @@ -357,8 +369,8 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
} } @else { - {{ testUrl() }} matches no file route. Angular throws NG04002 - "Cannot match any routes". + {{ testUrl() }} matches no file route. Angular + throws NG04002 "Cannot match any routes". @if (m.rejected.length) {
    @for (r of m.rejected.slice(0, 5); track $index) { @@ -389,7 +401,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth [class.dim]="row.route.kind === 'group' || row.route.kind === 'implicit'" > -
    +
    @if (row.depth) { } @@ -413,9 +425,11 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } - @for (e of serverExports(row.route); track e) { - {{ e }}() - } +
    + @for (e of serverExports(row.route); track e) { + {{ e }}() + } +
    @if (row.route.title) { @@ -430,7 +444,13 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } @empty { - No route matches the filter. + + @if (filter().trim()) { + No route matches the filter. Try part of a path or a file name. + } @else { + No file routes yet. Add a .page.ts file under src/app/pages. + } + } @@ -450,31 +470,41 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth server result.
    } -
    - Show calls of kind - @for (k of kinds; track k) { - - } -
    +
    +
    + Show calls of kind + @for (k of kinds; track k) { + + } +
    + +
    @if (calls().length) {
    - + - + @@ -488,15 +518,17 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }}
    TimeAt Kind Request StatusTimeDuration From
    - {{ c.method }} - {{ c.url }} - @if (c.render) { - {{ c.render === 'ssr' ? 'server rendered' : 'client only' }} - } +
    + {{ c.method }} + {{ c.url }} + @if (c.render) { + {{ c.render === 'ssr' ? 'server rendered' : 'client only' }} + } +
    @if (c.preview) {
    Response @@ -517,12 +549,21 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
    } @else { -

    - No calls yet. Navigate in the app to see page renders, load() fetches and API calls. -

    +
    +

    + @if (allCalls().length) { + No calls of this kind yet. + } @else { + No calls yet. + } +

    +

    + Navigate in the app to see page renders, load() fetches and API calls. +

    +
    } -

    API routes

    +

    API routes

    @@ -546,7 +587,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth >{{ base(api.file) }} - + } @empty { + + + }
    +
    + No API routes. Add a handler under src/server/routes to test it here. +
    -

    Request playground

    +

    Request playground

    - - + (valueChange)="method.set($event ?? 'GET')" + /> - +
    @if (method() !== 'GET') { @@ -609,8 +652,10 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth @if (response(); as r) {
    @if (r.error) { - Refused - {{ r.error }} +
    + Refused + {{ r.error }} +
    } @else {
    {{ @@ -626,11 +671,13 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } @case ('render') { -
    - @for (m of modeCounts(); track m.mode) { - {{ m.label }} · {{ m.count }} - } -
    + @if (modeCounts().length) { +
    + @for (m of modeCounts(); track m.mode) { + {{ m.label }} · {{ m.count }} + } +
    + }
    @@ -646,23 +693,27 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth + } @empty { + + + }
    {{ row.path }} - {{ - modeLabel(row.mode) - }} - {{ row.reason }} +
    + {{ + modeLabel(row.mode) + }} + {{ row.reason }} +
    @if (row.last; as last) { - {{ - last.status - }} - {{ last.render === 'client' ? 'client only' : 'server rendered' }} · - {{ last.ms }} ms - @if (mismatch(row)) { - differs from config - } +
    + {{ + last.status + }} + {{ last.render === 'client' ? 'client only' : 'server rendered' }} · + {{ last.ms }} ms + @if (mismatch(row)) { + differs from config + } +
    } @else { not requested yet } @@ -676,13 +727,19 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }
    + No page routes to render yet. Add a .page.ts file under src/app/pages. +
    @if (plan(); as p) {
    -

    Prerender plan

    +

    Prerender plan

    @if (p.dynamicConfig) {

    prerender.routes is a function, so the list is known only at build time. @@ -715,7 +772,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }

    Build output
    -
    +
    @if (p.built.length) { {{ p.built.length }} page(s) in dist/analog/public @if (p.notBuilt.length) { @@ -765,7 +822,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth {{ contentUrl(f.file) ?? '' }} {{ f.slug }} - {{ f.attributes['date'] || '' }} + {{ f.attributes['date'] || '' }} {{ dir(f.file) }}
    } @else { -

    No markdown files under src/content.

    +
    +

    No markdown files under src/content.

    +

    + Add a .md file there to see its title, slug, date and the URL it serves. +

    +
    } } @@ -822,7 +884,10 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth }
} @else { -
No Analog problems found.
+
+ No Analog problems found. Routes, server files, prerender config + and content all check out. +
} } } @@ -831,119 +896,257 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth `, styles: ` :host { - --good: #4ade80; - --warn: #facc15; - --bad: #f87171; + --good: var(--ok); + --bad: var(--danger); --info: #60a5fa; - --line: #27272a; - --soft: #18181b; + --mono: var(--font-mono); display: grid; - gap: 14px; - color: #e4e4e7; + gap: 16px; + min-width: 0; + color: var(--text); font-size: 13px; } - .pad { + .loading { + display: flex; + gap: 12px; + align-items: center; padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text-2); + } + .spinner { + width: 16px; + height: 16px; + flex: none; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + border-color: var(--accent-line); + } + } + .tnum { + font-variant-numeric: tabular-nums; + } + .meta { + display: flex; + flex-wrap: wrap; + gap: 6px 8px; + align-items: center; + min-width: 0; + } + .empty-row { + padding: 24px 16px; + color: var(--text-2); + text-align: center; + } + tbody tr:hover td.empty-row { + background: transparent; + } + .empty-box { + display: grid; + gap: 4px; + padding: 24px 16px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + text-align: center; + } + .empty-box p { + margin: 0; + line-height: 1.55; + } + .empty-lead { + color: var(--text-strong); + font-weight: 600; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; + font-family: var(--mono); + font-size: 12.5px; } .muted { - color: #a1a1aa; + color: var(--text-2); } .small { font-size: 12px; } - .pill + .small, - .status + .small { - margin-left: 8px; - } .nowrap { white-space: nowrap; } + code { + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text-strong); + font-family: var(--mono); + font-size: 12.5px; + } + .summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 12px; + animation: enter 0.35s var(--ease) both; } .stat { display: grid; - gap: 2px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--soft); + gap: 6px; + align-content: start; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + transition: + border-color 180ms var(--ease), + background-color 180ms var(--ease); + } + .stat:hover { + border-color: var(--border-strong); } .stat.wide { grid-column: span 2; + min-width: 0; + } + @media (max-width: 340px) { + .stat.wide { + grid-column: 1 / -1; + } } .stat .label { - color: #a1a1aa; + color: var(--text-3); font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; } .stat .value { - font-size: 18px; + color: var(--text-strong); + font-size: 20px; font-weight: 600; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .stat .value.mono { - font-size: 14px; + overflow: hidden; + color: var(--text); + font-size: 13px; + font-weight: 500; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; } .stat[data-tone='warn'] .value { color: var(--warn); } .stat[data-tone='good'] .value { - color: var(--good); + color: var(--ok); } + .tabs { - display: flex; + display: inline-flex; flex-wrap: wrap; - gap: 4px; - border-bottom: 1px solid var(--line); + gap: 2px; + justify-self: start; + max-width: 100%; + padding: 3px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); } [role='tab'] { display: inline-flex; - gap: 6px; + gap: 8px; align-items: center; - padding: 8px 12px; + min-height: 28px; + padding: 4px 12px; border: none; - border-bottom: 2px solid transparent; - background: none; - color: #d4d4d8; + border-radius: 7px; + background: transparent; + color: var(--text-2); font: inherit; + font-weight: 500; cursor: pointer; + transition: + background-color 180ms var(--ease), + color 180ms var(--ease), + box-shadow 180ms var(--ease); + } + [role='tab']:hover { + color: var(--text); } [role='tab'][aria-selected='true'] { - border-bottom-color: var(--accent); - color: #fafafa; + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); } .count { - min-width: 18px; + min-width: 20px; padding: 0 6px; - border-radius: 999px; - background: #27272a; - color: #d4d4d8; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); font-size: 11px; + font-weight: 600; + line-height: 18px; text-align: center; + font-variant-numeric: tabular-nums; + } + [role='tab'][aria-selected='true'] .count { + background: var(--border-strong); + color: var(--text); } - .count[data-tone='warn'] { - background: #422006; + .count[data-tone='warn'], + [role='tab'][aria-selected='true'] .count[data-tone='warn'] { + background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); } + .panel { display: grid; - gap: 12px; + gap: 16px; min-width: 0; + animation: enter 0.35s var(--ease) both; } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; align-items: center; justify-content: space-between; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + } + .explain { + flex: 1 1 320px; + min-width: 0; + } + .filter { + flex: 0 1 240px; + min-width: 0; + } + @media (max-width: 560px) { + .filter { + flex: 1 1 100%; + } } .inline, .row { @@ -952,95 +1155,195 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth gap: 8px; align-items: center; } + .inline > label, + .playground > label:not(.check) { + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .field { - padding: 6px 10px; - border: 1px solid #3f3f46; - border-radius: 8px; - background: var(--soft); - color: #e4e4e7; + box-sizing: border-box; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background-color: var(--bg); + color: var(--text); font: inherit; + font-size: 13px; + transition: + border-color 180ms var(--ease), + box-shadow 180ms var(--ease); + } + .field.mono { + font-family: var(--mono); + font-size: 13px; + } + .field::placeholder { + color: var(--text-3); + } + .field:focus, + .field:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + outline: none; + } + .method-select { + width: 112px; + font-family: var(--mono); } textarea.field { - width: 100%; - box-sizing: border-box; - resize: vertical; + height: auto; + padding: 8px 12px; } .grow { - flex: 1; - min-width: 180px; + flex: 1 1 180px; + min-width: 0; } + .btn { - padding: 6px 12px; - border: 1px solid #52525b; - border-radius: 8px; - background: #27272a; - color: #fafafa; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + height: 34px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); font: inherit; + font-size: 13px; + font-weight: 500; cursor: pointer; + transition: + background-color 160ms var(--ease), + border-color 160ms var(--ease), + color 160ms var(--ease); } .btn:hover { + background: var(--surface-3); + border-color: var(--border-strong); + } + .btn:active { + transform: translateY(1px); + } + .btn.primary { border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + .btn.primary:hover { + border-color: var(--accent-hover); + background: var(--accent-hover); } .btn.ghost { - padding: 2px 10px; - background: transparent; + height: 28px; + padding: 0 12px; + font-size: 12px; + } + .btn.ghost:hover { + border-color: var(--accent-line); + color: var(--text-strong); + } + td.actions { + width: 1%; + text-align: right; + vertical-align: middle; + } + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; } [role='tab']:focus-visible, .btn:focus-visible, - .field:focus-visible, .table-wrap:focus-visible, summary:focus-visible, .segmented label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } + .callout { - padding: 10px 12px; - border: 1px solid var(--line); - border-left: 3px solid var(--info); - border-radius: 8px; - background: var(--soft); + padding: 12px 16px; + border: 1px solid color-mix(in srgb, var(--info) 30%, transparent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--info) 8%, var(--surface)); line-height: 1.6; + animation: enter 0.35s var(--ease) both; } .callout[data-tone='good'] { - border-left-color: var(--good); + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); } .callout[data-tone='warn'] { - border-left-color: var(--warn); + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); } .callout[data-tone='bad'] { - border-left-color: var(--bad); + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + } + .callout strong { + color: var(--text-strong); + } + .callout strong.mono { + overflow-wrap: anywhere; } .chain { display: flex; flex-wrap: wrap; - gap: 6px; - margin: 6px 0 0; + gap: 8px; + margin: 8px 0 0; padding: 0; list-style: none; } + .chain li { + display: inline-flex; + gap: 6px; + align-items: center; + min-width: 0; + overflow-wrap: anywhere; + } .chain li:not(:last-child)::after { content: '›'; - margin-left: 6px; - color: #71717a; + margin-left: 2px; + color: var(--text-3); + } + .callout .chips { + margin-top: 8px; } .plain { - margin: 6px 0 0; + margin: 8px 0 0; padding-left: 18px; } + .plain li { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .table-wrap { overflow-x: auto; - border: 1px solid var(--line); - border-radius: 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + animation: enter 0.35s var(--ease) both; } table { width: 100%; + min-width: 560px; border-collapse: collapse; } th, td { - padding: 8px 10px; - border-bottom: 1px solid var(--line); + padding: 10px 12px; + border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } @@ -1048,89 +1351,120 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth border-bottom: none; } th { - background: var(--soft); - color: #a1a1aa; + position: sticky; + top: 0; + background: var(--surface); + color: var(--text-3); font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.06em; + white-space: nowrap; } .num { text-align: right; + font-variant-numeric: tabular-nums; + } + td.nowrap { + font-variant-numeric: tabular-nums; + } + tbody td { + transition: background-color 160ms var(--ease); } tbody tr:hover td { - background: #141417; + background: var(--surface-2); } tr.open td { - background: #1c1917; + background: var(--accent-soft); + color: var(--text-strong); + } + tr.open:hover td { + background: color-mix(in srgb, var(--accent) 16%, transparent); } tr.open td:first-child { - box-shadow: inset 3px 0 0 var(--accent); + box-shadow: inset 2px 0 0 var(--accent); } tr.dim .path { - color: #a1a1aa; + color: var(--text-2); } .route { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; align-items: center; } .guide { - color: #52525b; + color: var(--text-3); + font-family: var(--mono); } .path { - color: #f0abfc; + color: var(--text-strong); + overflow-wrap: anywhere; } .file { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; - color: #e4e4e7; + font-family: var(--mono); + font-size: 12.5px; + color: var(--text); overflow-wrap: anywhere; } .dir { - color: #a1a1aa; + color: var(--text-3); } + .pill, .chip, .status, .method { display: inline-block; padding: 1px 8px; - border-radius: 999px; + border-radius: 99px; font-size: 11px; + font-weight: 500; line-height: 18px; white-space: nowrap; } - .pill, - .chip { - border: 1px solid #3f3f46; - color: #d4d4d8; + .pill { + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text-2); } .chip { - border-radius: 6px; margin: 0 4px 4px 0; + padding: 3px 10px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 12px; + line-height: 16px; + } + .chip.mono { + font-size: 12px; } .chips { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; + } + .chips .pill { + padding: 3px 10px; + font-size: 12px; } .pill.live { - border-color: var(--accent); - color: #fda4af; + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--accent); } .pill[data-kind='layout'] { - border-color: #6366f1; - color: #c7d2fe; + border-color: #94a3b8; + color: #e2e8f0; } .pill[data-kind='markdown'] { border-color: #0ea5e9; color: #bae6fd; } .pill[data-kind='load'] { - border-color: #a855f7; - color: #e9d5ff; + border-color: #2dd4bf; + color: #99f6e4; } .pill[data-mode='ssr'] { border-color: #3b82f6; @@ -1149,8 +1483,8 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth color: #bfdbfe; } .pill[data-call='load'] { - border-color: #a855f7; - color: #e9d5ff; + border-color: #2dd4bf; + color: #99f6e4; } .pill[data-call='fn'] { border-color: #14b8a6; @@ -1162,173 +1496,269 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth } [data-tone='warn'].pill, [data-tone='warn'].chip { - border-color: #a16207; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } [data-tone='bad'].pill, [data-tone='bad'].chip { - border-color: #b91c1c; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } [data-tone='info'].pill { - border-color: #1d4ed8; + border-color: color-mix(in srgb, var(--info) 30%, transparent); + background: color-mix(in srgb, var(--info) 12%, transparent); color: #bfdbfe; } + .status { + border: 1px solid var(--border-strong); + font-family: var(--mono); font-weight: 600; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-numeric: tabular-nums; } .status[data-status='good'] { - background: #052e16; - color: #86efac; + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .status[data-status='warn'] { - background: #422006; - color: #fde68a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .status[data-status='bad'] { - background: #450a0a; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } .method { - min-width: 44px; - margin-right: 6px; - background: #27272a; - color: #e4e4e7; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + min-width: 52px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + font-family: var(--mono); font-weight: 600; text-align: center; } .method[data-method='GET'] { - background: #082f49; + border-color: color-mix(in srgb, #7dd3fc 30%, transparent); + background: color-mix(in srgb, #7dd3fc 12%, transparent); color: #7dd3fc; } .method[data-method='POST'] { - background: #052e16; + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); color: #86efac; } .method[data-method='PUT'], .method[data-method='PATCH'] { - background: #422006; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); color: #fde68a; } .method[data-method='DELETE'] { - background: #450a0a; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); color: #fecaca; } .request { min-width: 260px; } - .request .pill { - margin-left: 6px; + .request .url { + color: var(--text-strong); + overflow-wrap: anywhere; } details { - margin-top: 6px; + margin-top: 8px; } summary { - cursor: pointer; - color: #a1a1aa; + width: fit-content; + border-radius: 6px; + color: var(--text-2); font-size: 12px; + cursor: pointer; + transition: color 160ms var(--ease); + } + summary:hover { + color: var(--text); } .code { - margin: 6px 0 0; - padding: 8px 10px; + margin: 8px 0 0; + padding: 10px 12px; max-height: 220px; overflow: auto; - border-radius: 8px; - background: #09090b; - color: #e4e4e7; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } + + .calls-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + } .segmented { display: inline-flex; flex-wrap: wrap; gap: 2px; + justify-self: start; margin: 0; padding: 3px; - border: 1px solid var(--line); + border: 1px solid var(--border); border-radius: 10px; - background: var(--soft); - justify-self: start; + background: var(--bg); } .segmented label { - padding: 4px 10px; + display: inline-flex; + gap: 6px; + align-items: center; + padding: 5px 12px; border-radius: 7px; + background: transparent; + color: var(--text-2); + font-weight: 500; cursor: pointer; + transition: + background-color 180ms var(--ease), + color 180ms var(--ease), + box-shadow 180ms var(--ease); + } + .segmented label:hover { + color: var(--text); + } + .segmented label .muted { + color: var(--text-3); + font-size: 12px; + font-variant-numeric: tabular-nums; } .segmented label.on { - background: #3f3f46; - color: #fafafa; + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); } .segmented label.on .muted { - color: #d4d4d8; + color: var(--text-2); } - h3 { + + h2 { display: flex; gap: 8px; align-items: center; - margin: 6px 0 0; - color: #e4e4e7; - font-size: 13px; + margin: 8px 0 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; } .card { display: grid; - gap: 8px; - padding: 12px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--soft); + gap: 12px; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + animation: enter 0.35s var(--ease) both; } - .card h3 { + .card h2 { margin: 0; } .check { display: flex; - gap: 6px; + gap: 8px; align-items: center; + color: var(--text-2); + cursor: pointer; + } + .check input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--accent); + } + .check input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .response { display: grid; - gap: 6px; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + } + .response .code { + margin: 0; } .facts { display: grid; grid-template-columns: max-content 1fr; - gap: 6px 14px; + gap: 10px 16px; margin: 0; } .facts dt { - color: #a1a1aa; + padding-top: 3px; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; } .facts dd { margin: 0; } + .findings { display: grid; - gap: 8px; + gap: 12px; margin: 0; padding: 0; list-style: none; + animation: enter 0.35s var(--ease) both; } - .findings li { - padding: 10px 12px; - border: 1px solid var(--line); - border-left: 3px solid var(--info); - border-radius: 8px; - background: var(--soft); - } - .findings li[data-tone='bad'] { - border-left-color: var(--bad); - } - .findings li[data-tone='warn'] { - border-left-color: var(--warn); + .findings > li { + position: relative; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: + inset 3px 0 0 var(--info), + var(--shadow); + transition: border-color 180ms var(--ease); + } + .findings > li:hover { + border-color: var(--border-strong); + } + .findings > li[data-tone='bad'] { + box-shadow: + inset 3px 0 0 var(--danger), + var(--shadow); + } + .findings > li[data-tone='warn'] { + box-shadow: + inset 3px 0 0 var(--warn), + var(--shadow); } .findings p { - margin: 6px 0 0; - line-height: 1.5; + margin: 8px 0 0; + line-height: 1.55; + } + .findings > li > p { + color: var(--text-2); } .finding-head { display: flex; @@ -1337,47 +1767,67 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth align-items: center; } .finding-title { + color: var(--text-strong); font-size: 14px; - color: #fafafa; + font-weight: 600; } .where { display: grid; - gap: 4px; - margin: 8px 0 0; - padding: 8px 10px; - border-radius: 8px; - background: #0f0f11; + gap: 6px; + margin: 12px 0 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); list-style: none; } .where li { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 12px; } .fix { - margin-top: 8px; - color: #e4e4e7; - line-height: 1.5; + margin-top: 12px; + color: var(--text); + line-height: 1.55; } .fix strong { display: block; - margin-bottom: 2px; - color: var(--good); + margin-bottom: 4px; + color: var(--ok); font-size: 11px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; } .rule { display: block; - margin-top: 8px; - color: #a1a1aa; - } - .findings > li > p { - color: #d4d4d8; + margin-top: 12px; + color: var(--text-3); + font-size: 12px; } + .empty { - padding: 32px; + display: grid; + gap: 8px; + justify-items: center; + padding: 40px 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); text-align: center; + animation: enter 0.35s var(--ease) both; + } + .empty p { + margin: 0; + max-width: 520px; + line-height: 1.6; + } + .empty-title { + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; } .sr-only { position: absolute; @@ -1394,6 +1844,7 @@ export class AnalogInspector { readonly kinds: Kind[] = ['all', 'page', 'load', 'fn', 'api']; readonly methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + readonly methodOptions = this.methods.map((m) => ({ value: m, label: m })); readonly view = signal('routes'); readonly project = signal(null); readonly state = signal({}); @@ -1438,18 +1889,9 @@ export class AnalogInspector { !!r.route.file?.toLowerCase().includes(needle), ); }); - readonly duplicates = computed(() => { - const seen = new Map(); - const out = new Set(); - for (const c of this.allCalls()) { - if (c.kind !== 'load') continue; - const route = c.url.replace(/^.*\/_analog\/pages/, '').replace(/\/index$/, '') || '/'; - if (c.from === 'ssr') seen.set(route, c.at); - else if (c.from === 'browser' && (seen.get(route) ?? -Infinity) > c.at - 15_000) - out.add(route); - } - return Array.from(out); - }); + readonly duplicates = computed(() => + Array.from(new Set((this.state().duplicates ?? []).map((d) => d.route))), + ); readonly modeCounts = computed(() => (['ssr', 'ssg', 'client'] as const) .map((mode) => ({ @@ -1513,9 +1955,12 @@ export class AnalogInspector { } private async load(client: DevframeRpcClient) { - this.project.set(await call(client, 'analog-project')); + const project = await call(client, 'analog-project'); + if (this.destroyRef.destroyed) return; + this.project.set(project); try { const shared = await client.scope('ng-devtools').rpc.sharedState('analog'); + if (this.destroyRef.destroyed) return; const apply = (value: unknown) => this.state.set((value as AnalogState) ?? {}); apply(shared.value()); this.unsubscribe?.(); @@ -1523,6 +1968,7 @@ export class AnalogInspector { } catch { this.state.set({}); } + if (this.destroyRef.destroyed) return; await this.refresh(this.view()); } @@ -1639,6 +2085,10 @@ export class AnalogInspector { queueMicrotask(() => document.getElementById('api-path')?.focus()); } + async clearCalls() { + await call(this.rpc(), 'analog-clear-calls'); + } + async explain() { const url = this.testUrl().trim(); if (!url) return; diff --git a/app/src/pages/coming-soon.ts b/app/src/pages/coming-soon.ts new file mode 100644 index 0000000..65db35d --- /dev/null +++ b/app/src/pages/coming-soon.ts @@ -0,0 +1,483 @@ +import { Component, input } from '@angular/core'; + +export interface ComingSoonInfo { + id: 'nativescript' | 'capacitor' | 'analog'; + name: string; + badge: string; + heading: string; + summary: string; + color: string; + plans: string[]; + pr?: number; + author?: { name: string; login: string }; + link?: { label: string; href: string }; +} + +@Component({ + selector: 'app-coming-soon', + host: { '[style.--brand]': 'info().color' }, + template: ` +
+ + +

{{ info().badge }}

+

{{ info().heading }}

+

{{ info().summary }}

+ + @if (info().author; as author) { + + + + Being built by + {{ author.name }} + + + + (opens in a new tab) + + } + @if (info().link; as link) { + + {{ link.label }} + + (opens in a new tab) + + } + +

+ {{ info().link ? 'In an ' + info().name + ' app' : 'On the roadmap' }} +

+
    + @for (plan of info().plans; track plan; let i = $index) { +
  • {{ plan }}
  • + } +
+
+ `, + styles: ` + @use 'mixins' as m; + + :host { + display: block; + min-height: 100%; + background: radial-gradient( + 60% 50% at 50% 18%, + color-mix(in srgb, var(--brand) 22%, transparent), + transparent 70% + ); + } + .soon { + display: flex; + flex-direction: column; + align-items: center; + max-width: 560px; + margin: 0 auto; + padding: 24px 0 40px; + color: var(--text-2); + text-align: center; + } + .stage { + position: relative; + display: grid; + place-items: center; + width: 180px; + height: 180px; + margin: 0 auto 16px; + } + .logo { + position: relative; + z-index: 2; + display: grid; + place-items: center; + width: 116px; + height: 116px; + border-radius: 32px; + background: var(--surface-2); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--brand) 45%, transparent), + 0 20px 60px -10px color-mix(in srgb, var(--brand) 70%, transparent); + animation: float 4s ease-in-out infinite; + } + .ring { + position: absolute; + inset: 30px; + border-radius: 50%; + border: 1px solid color-mix(in srgb, var(--brand) 60%, transparent); + animation: pulse 3s ease-out infinite; + } + .r2 { + animation-delay: 1.5s; + } + .orbit { + position: absolute; + inset: 0; + animation: spin 9s linear infinite; + } + .o2 { + inset: 18px; + animation-duration: 6s; + animation-direction: reverse; + } + .o3 { + inset: 40px; + animation-duration: 12s; + } + .dot { + position: absolute; + top: -4px; + left: 50%; + width: 8px; + height: 8px; + margin-left: -4px; + border-radius: 50%; + background: var(--brand); + box-shadow: 0 0 12px var(--brand); + } + .o2 .dot { + width: 6px; + height: 6px; + background: var(--accent); + box-shadow: 0 0 10px var(--accent); + } + .o3 .dot { + width: 4px; + height: 4px; + background: var(--text-strong); + } + .badge { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + margin: 0 0 12px; + padding: 0 12px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--brand) 55%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--brand) 14%, var(--surface)); + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + } + .badge::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + rgba(255, 255, 255, 0.2) 50%, + transparent 80% + ); + transform: translateX(-100%); + animation: shimmer 2.8s ease-in-out infinite; + } + .spark { + color: var(--accent); + animation: twinkle 1.6s ease-in-out infinite; + } + h2 { + margin: 0; + color: var(--text-strong); + font-size: 28px; + line-height: 1.2; + letter-spacing: -0.02em; + text-wrap: balance; + overflow-wrap: anywhere; + } + .summary { + max-width: 480px; + margin: 12px 0 24px; + font-size: 14px; + line-height: 1.6; + text-wrap: pretty; + } + .author { + display: inline-flex; + align-items: center; + gap: 12px; + max-width: 100%; + min-height: 44px; + padding: 8px 16px 8px 8px; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + text-align: left; + text-decoration: none; + transition: + border-color 0.2s var(--ease), + background-color 0.2s var(--ease), + transform 0.2s var(--ease); + } + .author:hover { + border-color: color-mix(in srgb, var(--brand) 70%, transparent); + background: var(--surface-2); + transform: translateY(-2px); + } + .author:active { + transform: none; + } + .author:focus-visible { + @include m.focus-ring; + } + .cta { + padding: 8px 16px; + } + .author img { + flex: none; + border-radius: 50%; + background: var(--surface-3); + } + .who { + display: grid; + min-width: 0; + line-height: 1.35; + } + .who strong { + overflow: hidden; + color: var(--text-strong); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; + } + .by, + .login { + overflow: hidden; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; + } + .go { + flex: none; + margin-left: 4px; + color: var(--brand); + font-size: 16px; + transition: + transform 0.2s var(--ease), + color 0.2s var(--ease); + } + .author:hover .go, + .author:focus-visible .go { + transform: translateX(3px); + } + .plans-title { + @include m.label; + width: 100%; + max-width: 440px; + margin: 32px 0 12px; + text-align: left; + } + .plans { + display: grid; + gap: 8px; + width: 100%; + max-width: 440px; + margin: 0 auto; + padding: 0; + list-style: none; + text-align: left; + } + .plans li { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 13px; + line-height: 20px; + overflow-wrap: anywhere; + opacity: 0; + animation: rise 0.5s var(--ease) forwards; + } + .plans li::before { + content: ''; + flex: none; + width: 8px; + height: 8px; + margin-top: 6px; + border-radius: 50%; + background: var(--brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 20%, transparent); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + @media (max-height: 640px) { + .soon { + padding-top: 12px; + } + .stage { + width: 120px; + height: 120px; + margin-bottom: 8px; + } + .logo { + width: 80px; + height: 80px; + border-radius: 22px; + } + .logo svg { + width: 60px; + height: auto; + } + .o3 { + display: none; + } + h2 { + font-size: 22px; + } + } + @media (max-width: 420px) { + h2 { + font-size: 22px; + } + .author { + align-self: stretch; + } + .who { + flex: 1; + } + } + @keyframes float { + 50% { + transform: translateY(-10px) rotate(-2deg); + } + } + @keyframes pulse { + from { + transform: scale(0.8); + opacity: 0.9; + } + to { + transform: scale(1.6); + opacity: 0; + } + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @keyframes shimmer { + 60%, + 100% { + transform: translateX(100%); + } + } + @keyframes twinkle { + 50% { + opacity: 0.35; + transform: scale(0.8); + } + } + @keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } + } + @media (prefers-reduced-motion: reduce) { + .logo, + .ring, + .orbit, + .badge::after, + .spark { + animation: none; + } + .ring { + opacity: 0.3; + } + .plans li { + opacity: 1; + animation: none; + } + .author:hover, + .author:hover .go { + transform: none; + } + } + `, +}) +export class ComingSoon { + readonly info = input.required(); +} diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index f1e478e..bcb99f6 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -1,3 +1,4 @@ +import { JsonPipe } from '@angular/common'; import { Component, DestroyRef, @@ -9,327 +10,1201 @@ import { signal, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; -interface ComponentInfo { +interface SourceComponent { selector: string; + className: string; + kind: 'component' | 'directive'; file: string; + line: number; inputs: string[]; outputs: string[]; isStandalone: boolean; } +interface LiveNode { + id: string; + name: string; + tag: string; + directives?: string[]; + children: LiveNode[]; +} + +interface Prop { + name: string; + prop: string; + value?: unknown; + listened?: boolean; +} + +interface Dependency { + from: string; + token: string; + flags: string[]; + providedBy: string | null; + providedByName?: string; +} + +interface Detail { + id: string; + name: string; + tag: string; + path: string; + changeDetection?: string; + encapsulation?: string; + inputs: Prop[]; + outputs: Prop[]; + listeners: string[]; + directives: { name: string; inputs: Prop[]; outputs: Prop[] }[]; + dependencies: Dependency[]; +} + +interface Page { + pageId: string; + roots: LiveNode[]; + count: number; + truncated?: boolean; + detail: Detail | null; + reportedAt: number; +} + interface OutletInfo { outlet: string; route?: string; element?: string; + devtoolsId?: string; activated: boolean; children?: OutletInfo[]; } -interface ProviderEntry { - token: string; - source: string; - file: string; - line: number; - providedIn?: string; - type: string; +interface RoutedHit { + route: string; + outlet: string; +} + +interface Row { + node: LiveNode; + depth: number; + hasChildren: boolean; + expanded: boolean; +} + +function bare(name: string): string { + return name.replace(/^_(?=[A-Z])/, ''); } @Component({ selector: 'app-component-tree', + imports: [JsonPipe], template: ` -
- - -
- - @if (loading()) { -

Scanning components…

- } @else if (filtered().length === 0) { -

No components found.

- } @else { -
    - @for (comp of filtered(); track comp.selector) { -
  • - +
+ } +
+ @for (row of rows(); track row.node.id) { +
+ @if (row.hasChildren) { + + } @else { + + } + {{ row.node.name }} + <{{ row.node.tag }}> + @for (hit of routedById().get(row.node.id) ?? []; track hit.outlet + hit.route) { + {{ hit.route || '/' }} + } + @if (row.node.directives?.length) { + + +{{ row.node.directives!.length }} + + {{ row.node.directives!.length === 1 ? 'directive' : 'directives' }} + + + } +
+ } +
+ + + @if (selectedNode(); as sel) { +
+
+ component +

{{ sel.name }}

+ <{{ sel.tag }}> + @if (sourceFor(sel); as src) { +

{{ src.file }}:{{ src.line }}

+ @if (formsIn(src.file).length) { +
+ @for (form of formsIn(src.file); track form.formId) { + + } +
+ } } - - @if (isSelected(comp)) { -
-
-
File
-
{{ comp.file }}
-
Standalone
-
{{ comp.isStandalone ? 'Yes' : 'No' }}
-
- @for (form of formsIn(comp.file); track form.formId) { - +
+ + @if (detail(); as d) { +
+
Change detection
+
{{ d.changeDetection ?? 'Unknown' }}
+
Encapsulation
+
{{ d.encapsulation ?? 'Unknown' }}
+
Host path
+
{{ d.path }}
+ @for (hit of routedById().get(sel.id) ?? []; track hit.outlet + hit.route) { +
Routed
+
{{ hit.route || '/' }} in outlet {{ hit.outlet }}
} - @if (comp.inputs.length) { -

Inputs

-
    - @for (inp of comp.inputs; track inp) { -
  • {{ inp }}
  • +
+ +
+

+ Inputs {{ d.inputs.length }} +

+ @if (d.inputs.length) { +
    + @for (p of d.inputs; track p.name) { +
  • + + {{ p.name }} + @if (p.prop !== p.name) { + ({{ p.prop }}) + } + +
    {{ p.value | json }}
    +
  • }
+ } @else { +

No inputs.

} - @if (comp.outputs.length) { -

Outputs

-
    - @for (out of comp.outputs; track out) { -
  • {{ out }}
  • +
+ +
+

+ Outputs {{ d.outputs.length }} +

+ @if (d.outputs.length) { +
    + @for (p of d.outputs; track p.name) { +
  • + {{ p.name }} + {{ p.listened ? 'listened' : 'no listener' }} +
  • }
+ } @else { +

No outputs.

} - @if (selectedProviders().length) { -

Injected Providers

-
    - @for (p of selectedProviders(); track p.token + p.line) { -
  • - {{ p.token }} - {{ p.type }} - @if (p.source && p.source !== 'class' && p.source !== 'providers array') { - → {{ p.source }} +
+ + @if (d.listeners.length) { +
+

+ DOM listeners {{ d.listeners.length }} +

+
    + @for (name of d.listeners; track name) { +
  • {{ name }}
  • + } +
+
+ } + + @for (dir of d.directives; track dir.name + $index) { +
+

+ {{ dir.name }} + directive on host +

+ @if (dir.inputs.length) { +
    + @for (p of dir.inputs; track p.name) { +
  • + {{ p.name }} +
    {{ p.value | json }}
    +
  • + } +
+ } + @if (dir.outputs.length) { +
    + @for (p of dir.outputs; track p.name) { +
  • + {{ p.name }} + {{ + p.listened ? 'listened' : 'no listener' + }} +
  • + } +
+ } + @if (!dir.inputs.length && !dir.outputs.length) { +

No inputs or outputs.

+ } +
+ } + +
+

+ Injected {{ d.dependencies.length }} +

+ @if (d.dependencies.length) { +
    + @for (dep of d.dependencies; track dep.token + $index) { +
  • + {{ dep.token }} + @for (flag of dep.flags; track flag) { + {{ flag }} + } + @if (dep.providedBy) { + from + {{ + dep.providedByName ?? 'an injector' + }} + } @else { + not provided }
  • }
} @else { -

No injected providers detected.

+

Nothing is injected through the constructor or inject().

}
+ } @else { +
+ +

Reading live values from the page…

+
+ } +
+ } @else { +
+

+ Select a component to see its live inputs, outputs, change detection and injected + services. +

+
+ } + + } @else { + @if (page()) { +

+ The page reported no component instances. The live tree needs a development build with + Angular's debug API. Showing what the source declares instead. +

+ } @else { +

+ No page is connected, so this lists what the source declares. Open the app in a browser + with the devtools connected to see each rendered instance. +

+ } + +
+ + @if (source().length) { + + @if (filter().trim()) { + {{ filteredSource().length }} of {{ source().length }} + } @else { + {{ sourceCounts().components }} + {{ sourceCounts().components === 1 ? 'component' : 'components' }} · + {{ sourceCounts().directives }} + {{ sourceCounts().directives === 1 ? 'directive' : 'directives' }} } - + } - + +
+ + @if (loading() && !source().length) { +
+ +

Scanning components…

+
+ } @else if (error() && !source().length) { + + } @else if (!source().length) { +
+

No components found

+

No @Component or @Directive was found in the source.

+
+ } @else if (!filteredSource().length) { +
+

No components match “{{ filter().trim() }}”

+ +
+ } @else { +
    + @for (comp of filteredSource(); track sourceKey(comp); let i = $index) { +
  • + + @if (openKey() === sourceKey(comp)) { +
    +
    +
    Class
    +
    {{ bareName(comp.className) }}
    +
    File
    +
    {{ comp.file }}:{{ comp.line }}
    +
    Standalone
    +
    {{ comp.isStandalone ? 'Yes' : 'No' }}
    +
    Inputs
    +
    {{ comp.inputs.join(', ') || 'None' }}
    +
    Outputs
    +
    {{ comp.outputs.join(', ') || 'None' }}
    +
    + @for (form of formsIn(comp.file); track form.formId) { + + } +
    + } +
  • + } +
+ } } `, styles: ` + @use 'mixins' as m; + + :host { + display: block; + color: var(--text); + font-size: 13px; + } + .mono { + font-family: var(--font-mono); + } + .muted { + color: var(--text-2); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + .intro { + max-width: 720px; + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; + } + .notice { + margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + line-height: 1.5; + } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; - gap: 8px; - margin-bottom: 16px; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 12px; + padding: 8px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); } - input { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + .search { + position: relative; + flex: 1 1 220px; + min-width: 0; } - input:focus { - border-color: var(--accent); + .search-icon { + position: absolute; + top: 50%; + left: 12px; + width: 14px; + height: 14px; + transform: translateY(-50%); + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + stroke-linecap: round; + pointer-events: none; + } + .search:focus-within .search-icon { + stroke: var(--accent); + } + input[type='search'] { + width: 100%; + height: 34px; + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + } + input[type='search']::placeholder { + color: var(--text-3); + } + input[type='search']:focus-visible { + @include m.field-focus; + } + .count { + flex: none; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; } button { - padding: 8px 16px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 34px; + padding: 0 14px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); cursor: pointer; + font: inherit; font-size: 13px; + font-weight: 500; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); } button:hover { - background: #52525b; + background: var(--surface-3); + border-color: var(--accent-line); } - .muted { - color: #71717a; - font-size: 14px; + button:focus-visible { + @include m.focus-ring; } - .component-list { - list-style: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; + button svg { + flex: none; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; } - .component-item { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 0; - transition: border-color 0.15s; + .refresh.spinning svg { + animation: spin 0.8s linear infinite; } - .component-item:has(.component-toggle:hover) { - border-color: var(--accent); + @keyframes spin { + to { + transform: rotate(360deg); + } } - .component-item.expanded { - border-color: var(--accent); + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; } - .component-toggle { - display: block; - width: 100%; - padding: 12px 16px; - background: none; - border: none; - color: inherit; - text-align: left; + @media (min-width: 880px) { + .layout { + grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); + } + .detail { + position: sticky; + top: 64px; + max-height: calc(100vh - 150px); + overflow: auto; + } + } + .tree { + min-width: 0; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; + } + .row { + position: relative; + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); + border-radius: var(--radius-sm); cursor: pointer; - font: inherit; + transition: background-color 120ms var(--ease); } - .selector { - font-family: monospace; - font-size: 15px; - color: var(--accent); + .row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 14px; + width: calc(var(--depth, 0) * 18px); + background: repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px 18px); + pointer-events: none; + } + .row:hover { + background: var(--surface-2); + } + .row:focus-visible { + @include m.focus-ring(-2px); + } + .row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .twisty, + .twisty-space { + flex: none; + width: 20px; + height: 20px; + } + .twisty { + display: grid; + place-items: center; + border-radius: 4px; + color: var(--text-3); + } + .twisty:hover { + color: var(--text); + background: var(--surface-3); + } + .twisty svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 150ms var(--ease); + } + .twisty.open svg { + transform: rotate(90deg); + } + .name { + min-width: 0; + color: var(--text-strong); + font-size: 12px; font-weight: 600; + @include m.truncate; } - .file { + .tag { + flex: 0 1 auto; + min-width: 0; + color: var(--text-2); font-size: 12px; - color: #71717a; - margin-top: 2px; + @include m.truncate; } - .io { - font-size: 13px; - color: #a1a1aa; - margin-top: 4px; + .routed { + flex: none; + max-width: 40%; + padding: 0 8px; + border: 1px solid var(--accent-line); + border-radius: 99px; + background: var(--accent-soft); + color: var(--accent); + font-family: var(--font-mono); + font-size: 11px; + line-height: 18px; + @include m.truncate; } - .io .label { - color: #71717a; + .dirs { + flex: none; + margin-left: auto; + padding: 0 7px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + line-height: 18px; } - .show-form { - margin: 0 8px 8px 0; + .detail { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; } - .inline-detail { - padding: 0 16px 12px; - border-top: 1px solid #27272a; - margin-top: 0; - padding-top: 12px; + .detail.placeholder { + padding: 24px 16px; + border-style: dashed; + text-align: center; } - .prop-list { - list-style: none; - padding: 0; + .detail-head { display: flex; flex-wrap: wrap; - gap: 6px; - margin-bottom: 12px; + align-items: center; + gap: 6px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .prop-chip { - font-family: monospace; + .detail-head h2 { + min-width: 0; + margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; + } + .where { + width: 100%; + margin: 0; + color: var(--text-2); font-size: 12px; - padding: 3px 8px; - border-radius: 4px; + overflow-wrap: anywhere; } - .input-chip { - background: #1e3a5f; - color: #93c5fd; + .badge { + padding: 2px 8px; + border-radius: 99px; + @include m.soft(var(--accent)); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; } - .output-chip { - background: #3b1d1d; - color: #fca5a5; + .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + } + .show-form { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 600; } - dl { + .show-form:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + .facts { display: grid; - grid-template-columns: auto 1fr; - gap: 4px 12px; - font-size: 13px; - margin-bottom: 16px; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - dt { - color: #71717a; + .facts dt { + @include m.label; + align-self: center; } - dd { - color: #e4e4e7; + .facts dd { + min-width: 0; + margin: 0; + color: var(--text); + overflow-wrap: anywhere; } - h4 { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #71717a; - margin-bottom: 8px; + .block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .provider-list { - list-style: none; + .block:last-child { + border-bottom: 0; + } + h3 { + @include m.label; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + } + .pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + } + .props, + .deps, + .chips { + display: grid; + gap: 6px; + margin: 0; padding: 0; + list-style: none; + } + .chips { display: flex; - flex-direction: column; + flex-wrap: wrap; + } + .prop, + .dep { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + } + .prop-name { + display: block; + margin-bottom: 4px; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + } + pre { + max-height: 200px; + margin: 0; + overflow: auto; + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .chip { + display: inline-flex; + align-items: center; gap: 6px; + padding: 2px 10px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text); + font-size: 12px; + } + .chip.on { + @include m.soft(var(--ok)); } - .provider-item { + .chip-note { + color: var(--text-2); + font-size: 11px; + } + .chip.on .chip-note { + color: inherit; + } + .dep { display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + } + .token { + min-width: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; + } + .flag { + padding: 0 6px; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + } + .from { + margin-left: auto; + color: var(--text-2); + font-size: 12px; + } + .from .mono { + color: var(--text); + } + .from.missing { + color: var(--warn); + } + .empty-line { + margin: 0; + color: var(--text-2); + } + .state { + display: flex; + flex-direction: column; align-items: center; gap: 8px; - padding: 6px 10px; - background: #09090b; - border: 1px solid #27272a; - border-radius: 6px; - font-size: 13px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); } - .provider-token { - font-family: monospace; - color: #e4e4e7; + .state.compact { + padding: 24px 12px; + border: 0; + background: none; + } + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; font-weight: 600; + overflow-wrap: anywhere; } - .provider-type { - font-size: 11px; - padding: 1px 6px; - border-radius: 4px; - background: #3f3f46; - color: #a1a1aa; + .state-hint { + max-width: 440px; + margin: 0 auto; + color: var(--text-2); + line-height: 1.5; } - .routed { - display: inline-block; - margin-top: 4px; - padding: 1px 6px; - border: 1px solid #52525b; - border-radius: 4px; - color: #d4d4d8; - font-size: 11px; - font-family: monospace; + .spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @media (prefers-reduced-motion: reduce) { + .spinner, + .refresh.spinning svg { + animation: none; + } + } + .source-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + .source-item { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + } + .source-item.expanded { + border-color: var(--accent-line); + box-shadow: inset 2px 0 0 var(--accent); + } + .source-toggle { + justify-content: flex-start; + gap: 12px; + width: 100%; + height: auto; + min-height: 52px; + padding: 10px 16px; + background: none; + border: 0; + border-radius: 0; + text-align: left; + } + .source-toggle:hover { + background: var(--surface-2); + } + .source-toggle:focus-visible { + @include m.focus-ring(-2px); + } + .kind-flag { + flex: none; + padding: 1px 8px; + border-radius: 99px; + @include m.soft(var(--accent)); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .kind-flag.directive { + @include m.soft(var(--ok)); + } + .row-main { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; + } + .selector { + color: var(--text-strong); + font-weight: 600; + overflow-wrap: anywhere; } - .provider-source { + .file { + color: var(--text-2); font-size: 12px; - color: #a1a1aa; + @include m.truncate; } - .no-providers { - font-size: 13px; - color: #52525b; + .chevron { + flex: none; + width: 16px; + height: 16px; + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + transition: transform 200ms var(--ease); + } + .expanded .chevron { + transform: rotate(90deg); + stroke: var(--accent); + } + .inline-detail { + padding: 0 0 12px; + border-top: 1px solid var(--border); + } + .inline-detail .facts { + border-bottom: 0; + } + .inline-detail .show-form { + margin: 0 16px; } `, }) export class ComponentTree { - rpc = input(null); + readonly rpc = input(null); readonly showForm = output(); - formOwners = signal<{ formId: string; label: string; file: string | null }[]>([]); - components = signal([]); - allProviders = signal([]); - filter = signal(''); - loading = signal(false); - selected = signal(null); - selectedProviders = signal([]); - - filtered = signal([]); + readonly filter = signal(''); + readonly loading = signal(false); + readonly error = signal(false); + readonly source = signal([]); + readonly formOwners = signal<{ formId: string; label: string; file: string | null }[]>([]); + readonly pages = signal>({}); + readonly selectedId = signal(null); + readonly focusId = signal(null); + readonly collapsed = signal>(new Set()); + readonly openKey = signal(null); private readonly outlets = signal([]); - private unsubscribeRouter: (() => void) | null = null; + private readonly pageId = hostPageId(); private readonly destroyRef = inject(DestroyRef); + private readonly cleanups: (() => void)[] = []; + + readonly page = computed(() => { + const pages = this.pages(); + if (this.pageId) return pages[this.pageId] ?? null; + let latest: Page | null = null; + for (const page of Object.values(pages)) { + if (!latest || page.reportedAt > latest.reportedAt) latest = page; + } + return latest; + }); + + readonly live = computed(() => (this.page()?.roots.length ?? 0) > 0); - readonly routedBy = computed(() => { - const map = new Map(); + private readonly index = computed(() => { + const map = new Map(); + const parents = new Map(); + const walk = (nodes: LiveNode[], parent: string | null) => { + for (const node of nodes) { + map.set(node.id, node); + if (parent) parents.set(node.id, parent); + walk(node.children, node.id); + } + }; + walk(this.page()?.roots ?? [], null); + return { map, parents }; + }); + + private readonly query = computed(() => this.filter().trim().toLowerCase()); + + private readonly visible = computed(() => { + const roots = this.page()?.roots ?? []; + const q = this.query(); + if (!q) return roots; + const keep = (node: LiveNode) => + node.name.toLowerCase().includes(q) || + node.tag.toLowerCase().includes(q) || + !!node.directives?.some((d) => d.toLowerCase().includes(q)); + const prune = (nodes: LiveNode[]): LiveNode[] => + nodes.flatMap((node) => { + const children = prune(node.children); + return keep(node) || children.length ? [{ ...node, children }] : []; + }); + return prune(roots); + }); + + readonly rows = computed(() => { + const rows: Row[] = []; + const collapsed = this.collapsed(); + const searching = !!this.query(); + const walk = (nodes: LiveNode[], depth: number) => { + for (const node of nodes) { + const expanded = searching || !collapsed.has(node.id); + rows.push({ node, depth, hasChildren: node.children.length > 0, expanded }); + if (expanded) walk(node.children, depth + 1); + } + }; + walk(this.visible(), 0); + return rows; + }); + + readonly selectedNode = computed(() => { + const id = this.selectedId(); + return id ? (this.index().map.get(id) ?? null) : null; + }); + + readonly detail = computed(() => { + const detail = this.page()?.detail; + return detail && detail.id === this.selectedId() ? detail : null; + }); + + readonly rovingId = computed(() => { + const rows = this.rows(); + const ids = new Set(rows.map((r) => r.node.id)); + const focus = this.focusId(); + if (focus && ids.has(focus)) return focus; + const selected = this.selectedId(); + if (selected && ids.has(selected)) return selected; + return rows[0]?.node.id ?? null; + }); + + readonly routedById = computed(() => { + const map = new Map(); const visit = (outlets: OutletInfo[]) => { for (const outlet of outlets) { - if (outlet.activated && outlet.element && outlet.route) { - map.set(outlet.element, [ - ...(map.get(outlet.element) ?? []), - { route: outlet.route, outlet: outlet.outlet }, - ]); + if (outlet.activated && outlet.devtoolsId) { + const hits = map.get(outlet.devtoolsId) ?? []; + hits.push({ route: outlet.route ?? '', outlet: outlet.outlet }); + map.set(outlet.devtoolsId, hits); } if (outlet.children) visit(outlet.children); } @@ -338,36 +1213,74 @@ export class ComponentTree { return map; }); - constructor() { - effect(() => { - const q = this.filter().toLowerCase(); - const all = this.components(); - this.filtered.set(q ? all.filter((c) => c.selector.includes(q) || c.file.includes(q)) : all); - }); + private readonly sourceByClass = computed(() => { + const map = new Map(); + for (const comp of this.source()) { + const key = bare(comp.className); + map.set(key, [...(map.get(key) ?? []), comp]); + } + return map; + }); + + readonly filteredSource = computed(() => { + const q = this.query(); + const all = this.source(); + if (!q) return all; + return all.filter( + (c) => + c.selector.toLowerCase().includes(q) || + c.className.toLowerCase().includes(q) || + c.file.toLowerCase().includes(q), + ); + }); + + readonly sourceCounts = computed(() => { + const components = this.source().filter((c) => c.kind === 'component').length; + return { components, directives: this.source().length - components }; + }); + constructor() { effect(() => { const client = this.rpc(); - if (client) { - this.refresh(); - void this.watchRouter(client); - } + if (!client) return; + void this.refresh(); + void this.watch(client); + }); + this.destroyRef.onDestroy(() => { + this.highlight(null); + for (const cleanup of this.cleanups.splice(0)) cleanup(); }); - this.destroyRef.onDestroy(() => this.unsubscribeRouter?.()); } - private async watchRouter(client: DevframeRpcClient) { + private async watch(client: DevframeRpcClient) { + for (const cleanup of this.cleanups.splice(0)) cleanup(); + const my = client.scope('ng-devtools'); try { - const state = await client.scope('ng-devtools').rpc.sharedState('router'); + const tree = await my.rpc.sharedState('component-tree'); if (this.destroyRef.destroyed) return; - const apply = (value: unknown) => { + const applyTree = (value: unknown) => { + const pages = (value as { pages?: Record } | null)?.pages; + this.pages.set(pages && typeof pages === 'object' ? pages : {}); + }; + applyTree(tree.value()); + this.cleanups.push(tree.on('updated', applyTree)); + } catch { + this.pages.set({}); + } + try { + const router = await my.rpc.sharedState('router'); + if (this.destroyRef.destroyed) return; + const applyRouter = (value: unknown) => { const pages = - (value as { pages?: { outlets?: OutletInfo[]; snapshot?: unknown }[] })?.pages ?? []; - const page = pages.find((p) => p.snapshot) ?? pages[0]; + (value as { pages?: { pageId?: string; outlets?: OutletInfo[]; snapshot?: unknown }[] }) + ?.pages ?? []; + const page = this.pageId + ? pages.find((p) => p.pageId === this.pageId) + : (pages.find((p) => p.snapshot) ?? pages[0]); this.outlets.set(page?.outlets ?? []); }; - apply(state.value()); - this.unsubscribeRouter?.(); - this.unsubscribeRouter = state.on('updated', apply); + applyRouter(router.value()); + this.cleanups.push(router.on('updated', applyRouter)); } catch { this.outlets.set([]); } @@ -377,59 +1290,129 @@ export class ComponentTree { const client = this.rpc(); if (!client) return; this.loading.set(true); + const my = client.scope('ng-devtools'); try { - const my = client.scope('ng-devtools'); - const [comps, providers] = await Promise.all([ - my.rpc.call('get-components') as Promise, - my.rpc.call('get-providers') as Promise, - ]); - this.components.set(comps); - const owners = (await my.rpc.call('forms-owners').catch(() => [])) as { - formId: string; - label: string; - file: string | null; - }[]; - this.formOwners.set(owners ?? []); - this.allProviders.set(providers); - const sel = this.selected(); - if (sel) { - const refreshed = comps.find((c) => c.selector === sel.selector); - if (refreshed) { - this.selected.set(refreshed); - this.selectedProviders.set(providers.filter((p) => p.file === refreshed.file)); - } else { - this.selected.set(null); - this.selectedProviders.set([]); - } - } + this.source.set((await my.rpc.call('get-components')) as SourceComponent[]); + this.error.set(false); + } catch { + this.error.set(true); } finally { this.loading.set(false); } + const owners = (await my.rpc.call('forms-owners').catch(() => [])) as { + formId: string; + label: string; + file: string | null; + }[]; + this.formOwners.set(owners ?? []); } formsIn(file: string) { return this.formOwners().filter((form) => form.file === file); } - isSelected(comp: ComponentInfo): boolean { - return this.selected()?.selector === comp.selector; + sourceFor(node: LiveNode): SourceComponent | null { + const matches = (this.sourceByClass().get(bare(node.name)) ?? []).filter( + (c) => c.kind === 'component', + ); + return matches.find((c) => c.selector === node.tag) ?? matches[0] ?? null; } - select(comp: ComponentInfo) { - if (this.isSelected(comp)) { - this.selected.set(null); - this.selectedProviders.set([]); - const client = this.rpc(); - if (client) { - client.scope('ng-devtools').rpc.callEvent('select-component', null); - } - return; - } - this.selected.set(comp); - this.selectedProviders.set(this.allProviders().filter((p) => p.file === comp.file)); + sourceKey(comp: SourceComponent) { + return `${comp.file}#${comp.className}`; + } + + sourceLabel(comp: SourceComponent) { + if (!comp.selector) return bare(comp.className); + return comp.kind === 'component' && /^[a-z][\w-]*$/i.test(comp.selector) + ? `<${comp.selector}>` + : comp.selector; + } + + bareName(name: string) { + return bare(name); + } + + select(id: string) { + const next = this.selectedId() === id ? null : id; + this.selectedId.set(next); + this.focusId.set(id); const client = this.rpc(); - if (client) { - client.scope('ng-devtools').rpc.callEvent('select-component', comp.selector); + if (!client) return; + void client + .scope('ng-devtools') + .rpc.call('select-component', { pageId: this.page()?.pageId, id: next }) + .catch(() => {}); + } + + toggle(id: string, event?: Event) { + event?.stopPropagation(); + if (this.query()) return; + this.collapsed.update((set) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + highlight(id: string | null) { + const client = this.rpc(); + if (!client) return; + const target = id ? { pageId: this.page()?.pageId, id } : null; + void client + .scope('ng-devtools') + .rpc.call('request-page-highlight', target) + .catch(() => {}); + } + + onTreeKey(event: KeyboardEvent) { + const rows = this.rows(); + if (!rows.length) return; + const current = rows.findIndex((r) => r.node.id === this.rovingId()); + const at = Math.max(current, 0); + const row = rows[at]; + let next = -1; + switch (event.key) { + case 'ArrowDown': + next = Math.min(at + 1, rows.length - 1); + break; + case 'ArrowUp': + next = Math.max(at - 1, 0); + break; + case 'Home': + next = 0; + break; + case 'End': + next = rows.length - 1; + break; + case 'ArrowRight': + if (row.hasChildren && !row.expanded) this.toggle(row.node.id); + else if (row.hasChildren) next = at + 1; + break; + case 'ArrowLeft': + if (row.hasChildren && row.expanded && !this.query()) this.toggle(row.node.id); + else { + const parent = this.index().parents.get(row.node.id); + next = rows.findIndex((r) => r.node.id === parent); + } + break; + case 'Enter': + case ' ': + this.select(row.node.id); + break; + default: + return; + } + event.preventDefault(); + if (next >= 0) { + const id = rows[next].node.id; + this.focusId.set(id); + queueMicrotask(() => { + const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + el?.focus(); + el?.scrollIntoView({ block: 'nearest' }); + }); } } } diff --git a/app/src/pages/dashboard.ts b/app/src/pages/dashboard.ts index 8daf0d4..2402fe1 100644 --- a/app/src/pages/dashboard.ts +++ b/app/src/pages/dashboard.ts @@ -1,122 +1,498 @@ -import { Component, input, signal, effect, output } from '@angular/core'; +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; -import type { Tab } from '../types/tab.types'; +import { hostPageId } from '../page-id'; +import { TabIcon } from './tab-icon'; + +type LoadState = 'loading' | 'ready' | 'error'; + +interface BuildMeta { + projectName?: string; + angularVersion?: string; + typescript?: string; + ssr?: boolean; + analog?: string; +} + +const STATS = [ + { tab: 'components', label: 'Components' }, + { tab: 'routes', label: 'Routes' }, + { tab: 'signals', label: 'Signals' }, + { tab: 'injectors', label: 'Injectors' }, + { tab: 'store', label: 'NgRx declarations' }, + { tab: 'pipes', label: 'Pipes' }, +] as const; + +type StatTab = (typeof STATS)[number]['tab']; + +interface Card { + value: number; + sub: string; +} + +interface Row { + builtin?: boolean; + kind?: string; + type?: string; + redirectTo?: string; + fullPath?: string; + path?: string; +} + +interface InjectorNode { + providers?: unknown[]; + children?: InjectorNode[]; +} + +interface InjectorSnapshot { + roots?: InjectorNode[]; + environment?: InjectorNode[]; +} + +interface GraphSnapshot { + graph?: { nodes?: { kind?: string }[] } | null; + pages?: Record; +} + +const LIVE_SIGNAL_KINDS = new Set(['signal', 'computed', 'linkedSignal', 'effect']); + +const NGRX_NAMES: Record = { + action: ['action', 'actions'], + reducer: ['reducer', 'reducers'], + effect: ['effect', 'effects'], + selector: ['selector', 'selectors'], + feature: ['feature', 'features'], + 'store-setup': ['setup call', 'setup calls'], + 'signal-store': ['signal store', 'signal stores'], + 'signal-state': ['signal state', 'signal states'], + 'signal-method': ['signal method', 'signal methods'], +}; + +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + +function walkInjectors(nodes: InjectorNode[] | undefined, visit: (node: InjectorNode) => void) { + for (const node of nodes ?? []) { + visit(node); + walkInjectors(node.children, visit); + } +} + +export function componentsCard(rows: Row[]): Card { + if (!rows.some((row) => row.kind)) return { value: rows.length, sub: 'discovered in source' }; + const directives = rows.filter((row) => row.kind === 'directive').length; + return { + value: rows.length - directives, + sub: `components · ${plural(directives, 'directive', 'directives')}`, + }; +} + +export function routesCard(rows: Row[]): Card { + if (!rows.some((row) => row.kind)) return { value: rows.length, sub: 'route entries in source' }; + const redirects = rows.filter((row) => row.kind === 'redirect').length; + const paths = new Set( + rows.filter((row) => row.kind === 'page').map((row) => row.fullPath ?? row.path ?? ''), + ); + return { + value: paths.size, + sub: `navigable paths · ${plural(redirects, 'redirect', 'redirects')}`, + }; +} + +export function storeCard(rows: Row[]): Card { + const counts = new Map(); + for (const row of rows) if (row.kind) counts.set(row.kind, (counts.get(row.kind) ?? 0) + 1); + if (!counts.size) return { value: rows.length, sub: 'declarations in source' }; + const parts = [...counts] + .sort((a, b) => b[1] - a[1]) + .map(([kind, count]) => { + const [one, many] = NGRX_NAMES[kind] ?? [kind, kind]; + return plural(count, one, many); + }); + return { value: rows.length, sub: parts.join(' · ') }; +} @Component({ selector: 'app-dashboard', + imports: [TabIcon], template: ` -
-
-

Project

-
-
Name
-
{{ meta()?.projectName ?? '…' }}
-
Angular
-
{{ meta()?.angularVersion ?? '…' }}
-
TypeScript
-
{{ meta()?.typescript ?? '…' }}
-
SSR
-
{{ meta()?.ssr ? 'Yes' : 'No' }}
- @if (meta()?.analog; as analog) { -
Analog
-
{{ analog }}
+
+
+

Project

+

+ @switch (metaState()) { + @case ('ready') { + {{ meta()?.projectName }} + } + @case ('error') { + Project details unavailable + } + @default { + Loading… + } } -

-
-
-

Components

-

{{ componentCount() }}

-

discovered in source

-
-
-

Routes

-

{{ routeCount() }}

-

registered paths

-
-
-

Signals

-

{{ signalCount() }}

-

reactive primitives

-
-
-

Injectors

-

{{ providerCount() }}

-

DI providers

-
-
-

NgRx Store

-

{{ storeCount() }}

-

store entries

-
-
-

Pipes

-

{{ pipeCount() }}

-

template transformers

+ + @if (metaState() === 'error') { +

Check that the dev server is running, then reload the panel.

+ }
+
    +
  • Angular{{ meta()?.angularVersion ?? '…' }}
  • +
  • TypeScript{{ meta()?.typescript ?? '…' }}
  • +
  • SSR{{ meta() ? (meta()?.ssr ? 'On' : 'Off') : '…' }}
  • + @if (meta()?.analog; as analog) { +
  • Analog{{ analog }}
  • + } +
+ + +
+ @for (stat of stats; track stat.tab; let i = $index) { + @let state = stateOf(stat.tab); + + }
`, styles: ` - .grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 16px; + @use 'mixins' as m; + + :host { + display: block; + max-width: 1200px; } - .card { - background: #18181b; - border: 1px solid #27272a; - border-radius: 10px; - padding: 20px; + .project { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 16px 24px; + margin-bottom: 16px; + padding: 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: + radial-gradient(420px 180px at 100% 0%, var(--accent-soft), transparent 70%), + linear-gradient(180deg, var(--surface-2), var(--surface)); + box-shadow: var(--shadow); } - .card.clickable { - cursor: pointer; - transition: border-color 0.15s; + .project-head { + flex: 1 1 240px; + min-width: 0; } - .card.clickable:hover { - border-color: var(--accent); + .eyebrow { + @include m.label; + margin: 0 0 4px; + color: var(--accent); } h2 { + margin: 0; + color: var(--text-strong); + font-size: 24px; + line-height: 1.25; + letter-spacing: -0.02em; + overflow-wrap: anywhere; + } + h2.muted { + color: var(--text-2); + font-weight: 600; + } + .hint { + margin: 8px 0 0; + color: var(--text-2); font-size: 13px; - text-transform: uppercase; - color: #71717a; + line-height: 1.5; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; + } + .chips li { + display: inline-flex; + align-items: center; + gap: 8px; + height: 28px; + max-width: 100%; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: 99px; + background: var(--bg); + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .chips span { + color: var(--text-3); + font-weight: 500; + } + .chips.pending li { + color: var(--text-3); + } + .chips .analog { + border-color: color-mix(in srgb, #dd0330 55%, transparent); + } + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); + gap: 12px; + } + .stat { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + animation: enter 0.4s var(--ease) both; + transition: + border-color 0.2s var(--ease), + transform 0.2s var(--ease), + background-color 0.2s var(--ease), + box-shadow 0.2s var(--ease); + } + .stat:hover { + border-color: var(--accent-line); + background: var(--surface-2); + box-shadow: var(--shadow); + transform: translateY(-2px); + } + .stat:active { + transform: translateY(0); + background: var(--surface-3); + } + .stat:focus-visible { + @include m.focus-ring; + } + .top { + display: flex; + align-items: center; + justify-content: space-between; + align-self: stretch; margin-bottom: 12px; - letter-spacing: 0.05em; } - dl { + .icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--accent); + } + .go { display: grid; - grid-template-columns: auto 1fr; - gap: 6px 12px; - font-size: 14px; + place-items: center; + width: 24px; + height: 24px; + color: var(--text-3); + transition: + transform 0.2s var(--ease), + color 0.2s var(--ease); } - dt { - color: #a1a1aa; + .stat:hover .go, + .stat:focus-visible .go { + color: var(--accent); + transform: translateX(3px); } - dd { - color: #e4e4e7; + .label { + max-width: 100%; + overflow: hidden; + color: var(--text-2); + font-size: 13px; font-weight: 500; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; } .big { - font-size: 36px; - font-weight: 700; - color: var(--accent); + margin: 4px 0; + color: var(--text-strong); + font-size: 32px; + font-weight: 750; + line-height: 40px; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + } + .big.muted { + color: var(--text-3); + } + .skeleton { + display: block; + width: 56px; + height: 32px; + margin-block: 8px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2)) 0 0 / + 200% 100%; + animation: shimmer 1.4s linear infinite; } .sub { - font-size: 13px; - color: #71717a; - margin-top: 4px; + max-width: 100%; + color: var(--text-3); + font-size: 12px; + line-height: 16px; + } + @keyframes shimmer { + to { + background-position: -200% 0; + } + } + @media (max-width: 480px) { + .project { + padding: 16px; + } + h2 { + font-size: 20px; + } + .stat { + padding: 12px; + } + .big { + font-size: 28px; + line-height: 36px; + } + } + @media (prefers-reduced-motion: reduce) { + .stat, + .stat:hover, + .stat:active { + transform: none; + } + .stat:hover .go { + transform: none; + } + .skeleton { + animation: none; + } } `, }) export class Dashboard { rpc = input(null); - navigate = output(); + navigate = output(); + + meta = signal(null); + protected readonly stats = STATS; + protected readonly metaState = signal('loading'); + protected readonly states = signal>>({}); + private readonly rows = signal>>({}); + private readonly injectorTree = signal(null); + private readonly signalGraph = signal(null); + private readonly pageId = hostPageId(); + private readonly destroyRef = inject(DestroyRef); + private stopLive: (() => void)[] = []; + + private readonly liveInjectors = computed(() => { + const tree = this.injectorTree(); + if (!tree?.roots?.length) return null; + let injectors = 0; + let providers = 0; + const visit = (node: InjectorNode) => { + injectors++; + providers += node.providers?.length ?? 0; + }; + walkInjectors(tree.roots, visit); + walkInjectors(tree.environment, visit); + return { injectors, providers }; + }); + + private readonly liveSignals = computed(() => { + const state = this.signalGraph(); + const graph = (this.pageId && state?.pages?.[this.pageId]) || state?.graph; + if (!graph?.nodes?.length) return null; + return graph.nodes.filter((node) => LIVE_SIGNAL_KINDS.has(node.kind ?? '')).length; + }); - meta = signal(null); - componentCount = signal(0); - routeCount = signal(0); - signalCount = signal(0); - providerCount = signal(0); - storeCount = signal(0); - pipeCount = signal(0); + protected readonly cards = computed>>(() => { + const rows = this.rows(); + const out: Partial> = {}; + if (rows.components) out.components = componentsCard(rows.components); + if (rows.routes) out.routes = routesCard(rows.routes); + if (rows.store) out.store = storeCard(rows.store); + if (rows.pipes) { + const builtin = rows.pipes.filter((row) => row.builtin).length; + out.pipes = { + value: rows.pipes.length - builtin, + sub: builtin ? `custom pipes · ${builtin} built-in in use` : 'custom pipes in source', + }; + } + const liveSignals = this.liveSignals(); + if (liveSignals !== null) { + out.signals = { + value: liveSignals, + sub: rows.signals + ? `live on the page · ${rows.signals.length} declared in source` + : 'live on the page', + }; + } else if (rows.signals) { + out.signals = { value: rows.signals.length, sub: 'signal declarations in source' }; + } + const live = this.liveInjectors(); + if (live) { + out.injectors = { + value: live.injectors, + sub: `live injectors · ${plural(live.providers, 'provider', 'providers')}`, + }; + } else if (rows.injectors) { + out.injectors = { + value: rows.injectors.filter((row) => row.type !== 'injection').length, + sub: 'provider declarations in source', + }; + } + return out; + }); constructor() { effect(() => { @@ -124,34 +500,66 @@ export class Dashboard { if (!client) return; const my = client.scope('ng-devtools'); + this.metaState.set('loading'); + this.states.set({}); + this.rows.set({}); my.rpc .call('build-meta') - .then((m: any) => this.meta.set(m)) - .catch(() => {}); - my.rpc - .call('get-components') - .then((c: any[]) => this.componentCount.set(c.length)) - .catch(() => {}); - my.rpc - .call('get-routes') - .then((r: any[]) => this.routeCount.set(r.length)) - .catch(() => {}); - my.rpc - .call('get-signals') - .then((s: any[]) => this.signalCount.set(s.length)) - .catch(() => {}); - my.rpc - .call('get-providers') - .then((p: any[]) => this.providerCount.set(p.length)) - .catch(() => {}); - my.rpc - .call('get-ngrx-store') - .then((s: any[]) => this.storeCount.set(s.length)) - .catch(() => {}); - my.rpc - .call('get-pipes') - .then((s: any[]) => this.pipeCount.set(s.length)) - .catch(() => {}); + .then((m) => { + this.meta.set(m as BuildMeta); + this.metaState.set('ready'); + }) + .catch(() => this.metaState.set('error')); + this.load(my.rpc.call('get-components'), 'components'); + this.load(my.rpc.call('get-routes'), 'routes'); + this.load(my.rpc.call('get-signals'), 'signals'); + this.load(my.rpc.call('get-providers'), 'injectors'); + this.load(my.rpc.call('get-ngrx-store'), 'store'); + this.load(my.rpc.call('get-pipes'), 'pipes'); + void this.watchLive(client); }); + this.destroyRef.onDestroy(() => this.unwatch()); + } + + protected stateOf(tab: StatTab): LoadState { + const live = + tab === 'signals' ? this.liveSignals() : tab === 'injectors' ? this.liveInjectors() : null; + return live !== null ? 'ready' : (this.states()[tab] ?? 'loading'); + } + + private load(request: Promise, tab: StatTab) { + const mark = (state: LoadState) => this.states.update((all) => ({ ...all, [tab]: state })); + request + .then((rows) => { + this.rows.update((all) => ({ ...all, [tab]: Array.isArray(rows) ? (rows as Row[]) : [] })); + mark('ready'); + }) + .catch(() => mark('error')); + } + + private async watchLive(client: DevframeRpcClient) { + this.unwatch(); + const rpc = client.scope('ng-devtools').rpc; + const follow = async ( + name: 'injector-tree' | 'signal-graph', + target: (value: T) => void, + ) => { + try { + const state = await rpc.sharedState(name); + if (this.destroyRef.destroyed || this.rpc() !== client) return; + target(state.value() as T); + this.stopLive.push(state.on('updated', (value: unknown) => target(value as T))); + } catch { + target(null as T); + } + }; + await Promise.all([ + follow('injector-tree', (value) => this.injectorTree.set(value)), + follow('signal-graph', (value) => this.signalGraph.set(value)), + ]); + } + + private unwatch() { + for (const stop of this.stopLive.splice(0)) stop(); } } diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index 078cea3..a0e95e7 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -1,11 +1,36 @@ -import { NgTemplateOutlet } from '@angular/common'; -import { Component, input, signal, effect, computed } from '@angular/core'; +import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; interface ProviderInfo { token: string; type: string; isViewProvider: boolean; + multi?: boolean; + importPath?: string[]; +} + +interface DependencyInfo { + from: string; + token: string; + flags: string[]; + providedBy: string | null; +} + +interface InjectorNode { + injector: { + id: string; + type: string; + name: string; + providerCount: number; + component?: string; + directives?: string[]; + selector?: string; + path?: string[]; + }; + providers: ProviderInfo[]; + children: InjectorNode[]; + dependencies?: DependencyInfo[]; } interface SourceProvider { @@ -17,439 +42,1517 @@ interface SourceProvider { type: string; } -interface InjectorNode { - injector: { id: string; type: string; name: string; providerCount: number }; - providers: ProviderInfo[]; - children: InjectorNode[]; +interface Row { + node: InjectorNode; + depth: number; + hasChildren: boolean; + expanded: boolean; } -const TYPE_COLORS: Record = { - element: '#60a5fa', - environment: '#34d399', - null: '#71717a', +type TreeView = 'element' | 'environment'; + +const NULL_ID = 'inj-null'; + +const KIND_TONE: Record = { + component: 'var(--accent)', + directive: '#7cb4ff', + environment: 'var(--ok)', + null: 'var(--text-3)', }; +function kindOf(node: InjectorNode): 'component' | 'directive' | 'environment' { + if (node.injector.type !== 'element') return 'environment'; + return node.injector.component ? 'component' : 'directive'; +} + +function labelOf(node: InjectorNode): string { + return node.injector.type === 'element' ? `<${node.injector.name}>` : node.injector.name; +} + +function isTree(value: unknown): value is InjectorNode[] { + return Array.isArray(value); +} + @Component({ selector: 'app-di-inspector', - imports: [NgTemplateOutlet], template: ` -
- - -
- - @if (roots().length === 0 && sourceProviders().length === 0) { -
-

No DI data found.

-

- No providers, injectables, or inject() calls found. Runtime tree requires Angular 17+ with - the overlay connected. -

+ @if (roots().length > 0) { +

+ Every component and directive gets an injector. When it asks for a token, Angular walks up + this tree, then through the environment injectors, until something provides it. +

+ +
+
+ + +
+ + @if (view() === 'element') { + + } +
- } - @if (roots().length === 0 && sourceProviders().length > 0) { -

DI from source scan (static analysis):

-
- @for (group of groupedProviders(); track group.type) { -
-

{{ group.label }} ({{ group.items.length }})

-
- @for (p of group.items; track p.token + p.file + p.line) { -
-
- {{ p.token }} - @if (p.providedIn) { - providedIn: {{ p.providedIn }} - } -
-
- {{ p.file }}:{{ p.line }} - @if (p.source !== 'class' && p.source !== 'providers array') { - · as {{ p.source }} - } -
-
+ @if (providedBy().length) { +
+ Provided by + @for (hit of providedBy(); track hit.id) { + + } +
+ } @else if (query().trim() && !rows().length) { +
+ Nothing here matches “{{ query().trim() }}”. + +
+ } + +
+ @if (!rows().length) { +
+

No injectors to show

+

+ @if (onlyProviding()) { + None of them provide anything themselves. + } @else if (componentsOnly() && view() === 'element') { + Turn off “Components only” to see directives. + } @else { + Try a different search. } -

+

} -
- } - - @if (roots().length > 0) { -
- @for (root of filteredRoots(); track root.injector.id) { - - @defer { -
- @for (item of flattenTree(root); track item.node.injector.id) { -
+ @for (row of rows(); track row.node.injector.id; let i = $index) { +
+ @if (row.hasChildren) { +
+ } +
+ + @if (selected(); as sel) { +
+
+ {{ kind(sel) }} +

{{ label(sel) }}

+ @if (sel.injector.directives?.length) { +
    + @for (name of sel.injector.directives; track name) { +
  • {{ name }}
  • } -
+ } + + +
+

Lookup path

+

Angular asks these injectors in order until one has the token.

+
    + @for (step of path(); track step.id; let first = $first) { +
  1. + @if (step.id === nullId) { + + + Null injector + throws NullInjectorError + + } @else { + + } +
  2. + } +
- } + + @if (sel.injector.type === 'element') { +
+

+ Injected here {{ sel.dependencies?.length ?? 0 }} +

+ @if (sel.dependencies?.length) { +
    + @for (dep of sel.dependencies; track dep.from + dep.token + $index) { +
  • +
    + {{ dep.token }} + @for (flag of dep.flags; track flag) { + {{ flag }} + } +
    +
    + @if (sel.injector.directives && sel.injector.directives.length > 1) { + for {{ dep.from }} + } + @if (dep.providedBy; as by) { + + } @else { + not provided anywhere + } +
    +
  • + } +
+ } @else { +

Nothing is injected through the constructor or inject().

+ } +
+ } + +
+

+ Provides {{ sel.providers.length }} +

+ @if (sel.providers.length) { +
    + @for (p of sel.providers; track p.token + $index) { +
  • +
    + {{ p.token }} + {{ + p.type === 'unknown' ? 'provider' : 'use' + capital(p.type) + }} + @if (p.isViewProvider) { + viewProviders + } + @if (p.multi) { + multi + } +
    + @if (p.importPath?.length) { +
    via {{ p.importPath!.join(' › ') }}
    + } +
  • + } +
+ } @else { +

+ @if (sel.injector.type === 'element') { + No providers or viewProviders on this element. + } @else { + This injector has no providers of its own. + } +

+ } +
+ }
+ } @else if (!loaded()) { +
+ +

Loading dependency injection data…

+
+ } @else if (sourceProviders().length === 0) { +
+

No DI data found

+

+ Open the app in a browser with the devtools connected to see its live injectors. No + providers, injectables or inject() calls were found in the source either. +

+
+ } @else { +
+ + + {{ sourceMatchCount() }} of {{ sourceProviders().length }} + +
- @if (selectedInjector()) { - +
} } `, styles: ` + @use 'mixins' as m; + + :host { + display: block; + color: var(--text); + font-size: 13px; + } + .mono { + font-family: var(--font-mono); + } + .intro { + max-width: 720px; + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; + } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; - gap: 12px; + flex-wrap: wrap; + gap: 8px 12px; align-items: center; - margin-bottom: 16px; + margin: 0 0 12px; + padding: 8px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); } - input[type='text'] { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; + .segmented { + display: inline-flex; + flex: none; + padding: 3px; + gap: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + } + .segmented button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border: 0; border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + background: none; + color: var(--text-2); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease); } - input[type='text']:focus { - border-color: var(--accent); + .segmented button:hover { + color: var(--text); + } + .segmented button.active { + background: var(--surface-3); + color: var(--text-strong); + } + .segmented button:focus-visible { + @include m.focus-ring(-2px); + } + .pill { + display: inline-flex; + align-items: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + justify-content: center; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + font-variant-numeric: tabular-nums; + } + .segmented .active .pill { + background: var(--accent-soft); + color: var(--accent); + } + .search { + position: relative; + flex: 1 1 220px; + min-width: 0; + } + .search-icon { + position: absolute; + top: 50%; + left: 12px; + width: 14px; + height: 14px; + transform: translateY(-50%); + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + stroke-linecap: round; + pointer-events: none; + } + .search:focus-within .search-icon { + stroke: var(--accent); + } + input[type='search'] { + width: 100%; + height: 34px; + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + input[type='search']::placeholder { + color: var(--text-3); + } + input[type='search']:focus-visible { + @include m.field-focus; } .checkbox { display: flex; align-items: center; - gap: 6px; - font-size: 13px; - color: #a1a1aa; + gap: 8px; + height: 34px; + color: var(--text-2); white-space: nowrap; cursor: pointer; } - .empty { - text-align: center; - padding: 48px 16px; + .checkbox:hover { + color: var(--text); } - .muted { - color: #71717a; - font-size: 14px; + .checkbox input { + width: 15px; + height: 15px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; } - .hint { - color: #52525b; + .checkbox input:focus-visible { + @include m.focus-ring; + } + .total { + flex: none; + color: var(--text-2); font-size: 12px; - margin-top: 8px; + white-space: nowrap; } - .tree-container { + .where { display: flex; - flex-direction: column; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0 0 12px; + padding: 8px 10px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + @include m.enter(0.2s); + } + .where.muted { + border-color: var(--border); + background: var(--surface); + color: var(--text-2); + } + .where-label { + @include m.label; + margin-right: 4px; + color: var(--accent); + } + .chip, + .from, + .step { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + height: 26px; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: 99px; + background: var(--surface); + color: var(--text); + font-size: 12px; + cursor: pointer; + transition: + border-color 150ms var(--ease), + background-color 150ms var(--ease); + } + .chip:hover, + .from:hover, + .step:hover { + border-color: var(--accent-line); + background: var(--surface-2); + } + .chip:focus-visible, + .from:focus-visible, + .step:focus-visible, + .link:focus-visible { + @include m.focus-ring; + } + .chip-tokens { + color: var(--text-2); + @include m.truncate; + } + .link { + padding: 0; + border: 0; + background: none; + color: var(--accent); + font-size: 13px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; } - .injector-row { + .dot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + } + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; + } + @media (min-width: 880px) { + .layout { + grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); + } + .detail { + position: sticky; + top: 64px; + max-height: calc(100vh - 150px); + overflow: auto; + } + } + .tree { + min-width: 0; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; + } + .row { + position: relative; display: flex; align-items: center; - gap: 8px; - padding: 8px 12px; + gap: 6px; + min-height: 32px; + padding: 0 8px 0 calc(4px + var(--depth, 0) * 18px); + border-radius: var(--radius-sm); cursor: pointer; - border-bottom: 1px solid #1e1e22; - transition: background 0.1s; + transition: background-color 120ms var(--ease); } - .injector-row:hover { - background: #18181b; + .row::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 14px; + width: calc(var(--depth, 0) * 18px); + background: repeating-linear-gradient(to right, var(--border) 0 1px, transparent 1px 18px); + pointer-events: none; } - .injector-row.selected { - background: color-mix(in srgb, var(--accent) 22%, transparent); - border-color: var(--accent); + .row:hover { + background: var(--surface-2); } - .type-badge { - font-size: 10px; - padding: 2px 6px; + .row:focus-visible { + @include m.focus-ring(-2px); + } + .row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .twisty, + .twisty-space { + flex: none; + width: 20px; + height: 20px; + } + .twisty { + display: grid; + place-items: center; + padding: 0; + border: 0; border-radius: 4px; - color: #fff; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; + background: none; + color: var(--text-3); + cursor: pointer; + } + .twisty:hover { + color: var(--text); + background: var(--surface-3); + } + .twisty svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 150ms var(--ease); + } + .twisty.open svg { + transform: rotate(90deg); + } + .kind { + flex: none; + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 5px; + background: color-mix(in srgb, var(--tone) 16%, transparent); + color: var(--tone); + font-size: 10px; + font-weight: 700; } .name { - font-family: monospace; - font-size: 13px; - color: #e4e4e7; + min-width: 0; + color: var(--text); + font-size: 12px; + @include m.truncate; } - .provider-count { - font-size: 11px; - color: #71717a; + .row.selected .name { + color: var(--text-strong); + font-weight: 600; + } + .sub { + flex: 0 1 auto; + min-width: 0; + color: var(--text-3); + font-size: 12px; + @include m.truncate; + } + .meta { + display: inline-flex; + flex: none; + gap: 4px; margin-left: auto; + padding-left: 8px; + } + .count { + display: inline-flex; + align-items: center; + gap: 3px; + height: 20px; + padding: 0 7px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 11px; + } + .count.provides { + @include m.soft(var(--ok)); } - .detail-panel { - margin-top: 16px; - padding: 16px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; + .count svg { + width: 11px; + height: 11px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; } - .detail-header { + .detail { + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; + } + .detail-head { display: flex; + flex-wrap: wrap; align-items: center; - gap: 8px; - margin-bottom: 12px; + gap: 8px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .detail-header h3 { - font-family: monospace; - color: #e4e4e7; + .detail-head h2 { + flex: 1 1 auto; + min-width: 0; margin: 0; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; } - table { + .badge { + padding: 2px 8px; + border-radius: 99px; + background: color-mix(in srgb, var(--tone) 14%, transparent); + color: var(--tone); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .classes { + display: flex; + flex-wrap: wrap; + gap: 4px; width: 100%; - border-collapse: collapse; - font-size: 13px; + margin: 0; + padding: 0; + list-style: none; } - th { - text-align: left; - padding: 6px 10px; - background: #0f0f11; - color: #71717a; + .classes li { + padding: 1px 8px; + border: 1px solid var(--border-strong); + border-radius: 6px; + color: var(--text-2); + font-family: var(--font-mono); font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid #27272a; } - td { + .classes li.is-component { + border-color: var(--accent-line); + color: var(--accent); + } + .block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .block:last-child { + border-bottom: 0; + } + h3 { + @include m.label; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + } + .hint { + margin: -4px 0 10px; + color: var(--text-3); + font-size: 12px; + } + .path { + position: relative; + display: grid; + gap: 6px; + margin: 0; + padding: 0 0 0 18px; + list-style: none; + } + .path::before { + content: ''; + position: absolute; + top: 13px; + bottom: 13px; + left: 5px; + width: 1px; + background: var(--border-strong); + } + .path li { + position: relative; + min-width: 0; + } + .path li::before { + content: ''; + position: absolute; + top: 12px; + left: -16px; + width: 9px; + height: 1px; + background: var(--border-strong); + } + .path li.current .step { + border-color: var(--accent); + background: var(--accent-soft); + } + .step.null { + cursor: default; + color: var(--text-2); + } + .step.null:hover { + border-color: var(--border-strong); + background: var(--surface); + } + .hint-inline { + color: var(--text-3); + } + .step-count { + color: var(--text-3); + font-size: 11px; + } + .deps, + .providers { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .dep, + .provider { + min-width: 0; padding: 8px 10px; - border-bottom: 1px solid #1e1e22; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + } + .dep.hit, + .provider.hit { + border-color: var(--accent-line); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .dep-main { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; } .token { - font-family: monospace; - color: var(--accent); + min-width: 0; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; + } + .flag { + padding: 0 6px; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 10px; + line-height: 16px; } - .source-label { + .kind-flag { + @include m.soft(var(--accent)); + } + .dep-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + margin-top: 6px; + color: var(--text-3); + font-size: 12px; + overflow-wrap: anywhere; + } + .from { + height: 22px; + padding: 0 8px; + color: var(--text-2); + } + .from .mono { + color: var(--text); + } + .from.missing { + cursor: default; + border-color: color-mix(in srgb, var(--warn) 40%, transparent); + background: none; + color: var(--warn); + } + .empty-line { + margin: 0; + color: var(--text-2); + } + .state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + @include m.enter; + } + .state.compact { + padding: 24px 12px; + border: 0; + } + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .state-hint { + max-width: 440px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } + .spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + } + button { + font: inherit; + } + .state button, + .provider-group button { + height: 34px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + } + .state-title + .state-hint + button { + margin-top: 8px; + } + .notice { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 16px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); font-size: 13px; - color: #71717a; - margin-bottom: 12px; + line-height: 1.5; + } + .notice strong { + color: var(--text); + font-weight: 600; + } + .notice svg { + flex: none; + width: 16px; + height: 16px; + margin-top: 2px; + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; } .source-providers { display: flex; flex-direction: column; - gap: 20px; + gap: 24px; + @include m.enter; } - .provider-group h3 { - font-size: 13px; - color: #71717a; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 8px; + .provider-group h2 { + @include m.label; + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + } + .section-count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + letter-spacing: 0; + line-height: 16px; + font-variant-numeric: tabular-nums; } .provider-list { + list-style: none; + margin: 0; + padding: 0; display: flex; flex-direction: column; gap: 6px; } .provider-card { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 10px 14px; + min-width: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 8px 12px; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + .provider-card:hover { + background: var(--surface-2); + border-color: var(--border-strong); } .provider-header { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 4px 8px; } .provider-header .token { - font-size: 14px; - font-weight: 500; + font-size: 13px; + font-weight: 600; } .provided-in { font-size: 11px; - padding: 1px 6px; - border-radius: 4px; - background: #14532d; - color: #4ade80; + padding: 1px 8px; + border-radius: 99px; + @include m.soft(var(--ok)); + border-width: 1px; + border-style: solid; } .provider-meta { - font-size: 11px; - color: #52525b; + display: flex; + flex-wrap: wrap; + gap: 0 8px; margin-top: 4px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-3); + } + .provider-meta .path { + min-width: 0; + overflow-wrap: anywhere; + } + .provider-meta .as { + color: var(--text-2); } `, }) export class DiInspector { rpc = input(null); - roots = signal([]); - sourceProviders = signal([]); - filter = signal(''); - hideEmpty = signal(false); - selectedId = signal(null); + readonly nullId = NULL_ID; + readonly roots = signal([]); + readonly environment = signal([]); + readonly sourceProviders = signal([]); + readonly loaded = signal(false); + readonly view = signal('element'); + readonly query = signal(''); + readonly onlyProviding = signal(false); + readonly componentsOnly = signal(true); + readonly collapsed = signal>(new Set()); + readonly selectedId = signal(null); + readonly focusId = signal(null); + + private readonly index = computed(() => { + const map = new Map(); + const parents = new Map(); + const walk = (nodes: InjectorNode[], parent: string | null) => { + for (const node of nodes) { + map.set(node.injector.id, node); + if (parent) parents.set(node.injector.id, parent); + walk(node.children, node.injector.id); + } + }; + walk(this.roots(), null); + walk(this.environment(), null); + return { map, parents }; + }); - selectedInjector = computed(() => { + readonly elementCount = computed(() => this.count(this.roots())); + readonly environmentCount = computed(() => this.count(this.environment())); + + private readonly visible = computed(() => { + let nodes = this.view() === 'element' ? this.roots() : this.environment(); + if (this.view() === 'element' && this.componentsOnly()) { + nodes = this.prune(nodes, (n) => !!n.injector.component); + } + if (this.onlyProviding()) nodes = this.prune(nodes, (n) => n.providers.length > 0); + const q = this.normalizedQuery(); + if (q) nodes = this.prune(nodes, (n) => this.nodeMatches(n, q)); + return nodes; + }); + + readonly rows = computed(() => { + const rows: Row[] = []; + const collapsed = this.collapsed(); + const searching = !!this.normalizedQuery(); + const walk = (nodes: InjectorNode[], depth: number) => { + for (const node of nodes) { + const expanded = searching || !collapsed.has(node.injector.id); + rows.push({ node, depth, hasChildren: node.children.length > 0, expanded }); + if (expanded) walk(node.children, depth + 1); + } + }; + walk(this.visible(), 0); + return rows; + }); + + readonly selected = computed(() => { const id = this.selectedId(); - if (!id) return null; - return this.findNode(this.roots(), id); + const found = id ? this.index().map.get(id) : undefined; + return found ?? this.rows()[0]?.node ?? null; + }); + + readonly rovingId = computed(() => { + const rows = this.rows(); + const want = this.focusId() ?? this.selected()?.injector.id; + if (want && rows.some((r) => r.node.injector.id === want)) return want; + return rows[0]?.node.injector.id ?? null; }); - filteredRoots = computed(() => { - let roots = this.roots(); - if (this.hideEmpty()) { - roots = this.filterEmpty(roots); + readonly path = computed(() => { + const sel = this.selected(); + if (!sel) return []; + const { map, parents } = this.index(); + let ids = sel.injector.path ?? []; + if (!ids.length) { + ids = [sel.injector.id]; + let parent = parents.get(sel.injector.id); + while (parent) { + ids.push(parent); + parent = parents.get(parent); + } + ids.push(NULL_ID); } - const q = this.filter().toLowerCase(); - if (q) { - roots = this.filterByQuery(roots, q); + return ids.map((id) => { + const node = map.get(id); + return { + id, + label: node ? labelOf(node) : 'Null injector', + kind: node ? kindOf(node) : 'null', + providers: node?.providers.length ?? 0, + }; + }); + }); + + readonly providedBy = computed(() => { + const q = this.normalizedQuery(); + if (!q) return []; + const hits: { id: string; label: string; kind: string; tokens: string }[] = []; + for (const node of this.index().map.values()) { + const tokens = node.providers.filter((p) => p.token.toLowerCase().includes(q)); + if (!tokens.length) continue; + hits.push({ + id: node.injector.id, + label: labelOf(node), + kind: kindOf(node), + tokens: tokens.map((t) => t.token).join(', '), + }); } - return roots; + return hits.slice(0, 12); }); - groupedProviders = computed(() => { - const all = this.sourceProviders(); - const q = this.filter().toLowerCase(); + readonly groupedProviders = computed(() => { + const q = this.normalizedQuery(); const filtered = q - ? all.filter((p) => p.token.toLowerCase().includes(q) || p.file.includes(q)) - : all; - + ? this.sourceProviders().filter( + (p) => p.token.toLowerCase().includes(q) || p.file.toLowerCase().includes(q), + ) + : this.sourceProviders(); const groups: { type: string; label: string; items: SourceProvider[] }[] = [ { type: 'root-provider', label: 'Root Providers (provide*)', items: [] }, { type: 'injectable', label: 'Injectable Services', items: [] }, { type: 'injection', label: 'inject() Calls', items: [] }, { type: 'provider', label: 'Component Providers', items: [] }, ]; - for (const p of filtered) { - const group = groups.find((g) => g.type === p.type); - if (group) group.items.push(p); - } + for (const p of filtered) groups.find((g) => g.type === p.type)?.items.push(p); return groups.filter((g) => g.items.length > 0); }); + readonly sourceMatchCount = computed(() => + this.groupedProviders().reduce((sum, g) => sum + g.items.length, 0), + ); + + private readonly normalizedQuery = computed(() => this.query().trim().toLowerCase()); + + private readonly destroyRef = inject(DestroyRef); + private stopTree: (() => void) | null = null; + constructor() { effect(() => { const client = this.rpc(); if (!client) return; - this.loadInjectorTree(client); - this.loadSourceProviders(client); + void this.loadInjectorTree(client); + void this.loadSourceProviders(client); + }); + this.destroyRef.onDestroy(() => { + this.stopTree?.(); + this.highlight(null); }); } - async loadInjectorTree(client: DevframeRpcClient) { - const my = client.scope('ng-devtools'); - const state = await my.rpc.sharedState('injector-tree'); - const val = state.value() as any; - if (val?.roots?.length) this.roots.set(val.roots); - state.on('updated', (next: any) => { - if (next?.roots) this.roots.set(next.roots); - }); + label(node: InjectorNode) { + return labelOf(node); } - async loadSourceProviders(client: DevframeRpcClient) { - const my = client.scope('ng-devtools'); - try { - const result = (await my.rpc.call('get-providers')) as SourceProvider[]; - this.sourceProviders.set(result); - } catch { - // RPC not available - } + kind(node: InjectorNode) { + return kindOf(node); } - select(node: InjectorNode) { - this.selectedId.set(this.selectedId() === node.injector.id ? null : node.injector.id); + tone(kind: string) { + return KIND_TONE[kind] ?? KIND_TONE['null']; } - typeColor(type: string) { - return TYPE_COLORS[type] ?? TYPE_COLORS['null']; + kindById(id: string) { + const node = this.index().map.get(id); + return node ? kindOf(node) : 'null'; } - flattenTree(root: InjectorNode): { node: InjectorNode; depth: number }[] { - const result: { node: InjectorNode; depth: number }[] = []; - const walk = (node: InjectorNode, depth: number) => { - result.push({ node, depth }); - for (const child of node.children) { - walk(child, depth + 1); + labelById(id: string) { + const node = this.index().map.get(id); + return node ? labelOf(node) : 'Null injector'; + } + + capital(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); + } + + matches(token: string) { + const q = this.normalizedQuery(); + return !!q && token.toLowerCase().includes(q); + } + + setView(view: TreeView) { + if (this.view() === view) return; + this.view.set(view); + this.selectedId.set(null); + this.focusId.set(null); + } + + select(id: string) { + this.selectedId.set(id); + this.focusId.set(id); + } + + toggle(id: string, event?: Event) { + event?.stopPropagation(); + if (this.normalizedQuery()) return; + this.collapsed.update((set) => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + reveal(id: string) { + const node = this.index().map.get(id); + if (!node) return; + this.setView(node.injector.type === 'element' ? 'element' : 'environment'); + const { parents } = this.index(); + this.collapsed.update((set) => { + const next = new Set(set); + let parent = parents.get(id); + while (parent) { + next.delete(parent); + parent = parents.get(parent); } + return next; + }); + if (this.onlyProviding() && node.providers.length === 0) this.onlyProviding.set(false); + if (node.injector.type === 'element' && !node.injector.component) + this.componentsOnly.set(false); + this.select(id); + queueMicrotask(() => this.focusRow(id, false)); + } + + highlight(node: InjectorNode | null) { + const client = this.rpc(); + if (!client) return; + const selector = node?.injector.type === 'element' ? (node.injector.selector ?? null) : null; + void client + .scope('ng-devtools') + .rpc.call('request-page-highlight', selector) + .catch(() => {}); + } + + onTreeKey(event: KeyboardEvent) { + const rows = this.rows(); + if (!rows.length) return; + const roving = this.rovingId(); + const current = rows.findIndex((r) => r.node.injector.id === roving); + const at = Math.max(current, 0); + const row = rows[at]; + let next = -1; + switch (event.key) { + case 'ArrowDown': + next = Math.min(at + 1, rows.length - 1); + break; + case 'ArrowUp': + next = Math.max(at - 1, 0); + break; + case 'Home': + next = 0; + break; + case 'End': + next = rows.length - 1; + break; + case 'ArrowRight': + if (row.hasChildren && !row.expanded) this.toggle(row.node.injector.id); + else if (row.hasChildren) next = at + 1; + break; + case 'ArrowLeft': + if (row.hasChildren && row.expanded && !this.normalizedQuery()) + this.toggle(row.node.injector.id); + else { + const parent = this.index().parents.get(row.node.injector.id); + next = rows.findIndex((r) => r.node.injector.id === parent); + } + break; + case 'Enter': + case ' ': + this.select(row.node.injector.id); + break; + default: + return; + } + event.preventDefault(); + if (next >= 0) { + const id = rows[next].node.injector.id; + this.focusId.set(id); + this.selectedId.set(id); + queueMicrotask(() => this.focusRow(id, true)); + } + } + + private focusRow(id: string, focus: boolean) { + const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); + if (!el) return; + if (focus) el.focus(); + el.scrollIntoView({ block: 'nearest' }); + } + + private async loadInjectorTree(client: DevframeRpcClient) { + this.stopTree?.(); + this.stopTree = null; + const my = client.scope('ng-devtools'); + let state: Awaited>; + try { + state = await my.rpc.sharedState('injector-tree'); + } catch { + return; + } + if (this.destroyRef.destroyed) return; + const pageId = hostPageId(); + const apply = (value: unknown) => { + const shared = value as { + roots?: unknown; + environment?: unknown; + pages?: Record; + } | null; + const next = (pageId ? shared?.pages?.[pageId] : undefined) ?? shared; + if (isTree(next?.roots)) this.roots.set(next.roots); + if (isTree(next?.environment)) this.environment.set(next.environment); }; - walk(root, 0); - return result; + apply(state.value()); + this.stopTree = state.on('updated', apply); } - private findNode(nodes: InjectorNode[], id: string): InjectorNode | null { - for (const n of nodes) { - if (n.injector.id === id) return n; - const found = this.findNode(n.children, id); - if (found) return found; + private async loadSourceProviders(client: DevframeRpcClient) { + const my = client.scope('ng-devtools'); + try { + this.sourceProviders.set((await my.rpc.call('get-providers')) as SourceProvider[]); + } catch { + this.sourceProviders.set([]); + } finally { + this.loaded.set(true); } - return null; } - private filterEmpty(nodes: InjectorNode[]): InjectorNode[] { - return nodes - .map((n) => ({ - ...n, - children: this.filterEmpty(n.children), - })) - .filter((n) => n.injector.providerCount > 0 || n.children.length > 0); + private count(nodes: InjectorNode[]): number { + return nodes.reduce((sum, n) => sum + 1 + this.count(n.children), 0); } - private filterByQuery(nodes: InjectorNode[], q: string): InjectorNode[] { - return nodes - .map((n) => ({ - ...n, - children: this.filterByQuery(n.children, q), - })) - .filter( - (n) => - n.injector.name.toLowerCase().includes(q) || - n.providers.some((p) => p.token.toLowerCase().includes(q)) || - n.children.length > 0, - ); + private nodeMatches(node: InjectorNode, q: string): boolean { + const i = node.injector; + return ( + i.name.toLowerCase().includes(q) || + !!i.component?.toLowerCase().includes(q) || + !!i.directives?.some((d) => d.toLowerCase().includes(q)) || + node.providers.some((p) => p.token.toLowerCase().includes(q)) || + !!node.dependencies?.some((d) => d.token.toLowerCase().includes(q)) + ); + } + + private prune(nodes: InjectorNode[], keep: (node: InjectorNode) => boolean): InjectorNode[] { + const out: InjectorNode[] = []; + for (const node of nodes) { + const children = this.prune(node.children, keep); + if (keep(node) || children.length) out.push({ ...node, children }); + } + return out; } } diff --git a/app/src/pages/forms-field-detail.ts b/app/src/pages/forms-field-detail.ts index f769aae..aeb1003 100644 --- a/app/src/pages/forms-field-detail.ts +++ b/app/src/pages/forms-field-detail.ts @@ -13,22 +13,30 @@ import { @Component({ selector: 'app-forms-field-detail', template: ` -

{{ node().path || '(form)' }}

-
{{ text() || 'Loading…' }}
-
- @if (node().type === 'control' && !node().redacted) { +
+ +

{{ node().path || '(form)' }}

+ {{ node().type }} +
+
{{ text() || 'Loading…' }}
+ @if (node().type === 'control' && !node().redacted) { +
- - } + +
+ } +
@@ -39,31 +47,56 @@ import { styles: ` ${FORMS_STYLES} :host { - display: grid; - gap: 8px; - padding: 10px; - border: 1px solid #3f3f46; - border-radius: 8px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 16px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + animation: enter 0.35s var(--ease) both; + } + .head { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + min-width: 0; + } + .head .section-label { + flex-basis: 100%; } h3 { + min-width: 0; margin: 0; - color: #e4e4e7; + color: var(--text-strong); + font-family: var(--font-mono); font-size: 14px; - font-family: ui-monospace, monospace; + font-weight: 600; + line-height: 1.4; + overflow-wrap: anywhere; + } + .editor { + display: flex; + gap: 8px; + align-items: center; + } + .editor .field-input { + flex: 1 1 auto; } .row { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; align-items: center; } - .sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + .status { + margin-top: -4px; + } + .status:empty { + height: 0; + margin: -12px 0 0; } `, }) @@ -105,18 +138,12 @@ export class FormsFieldDetail { } async setValue() { - const raw = this.draft(); - let value: unknown = raw; - try { - value = JSON.parse(raw); - } catch { - value = raw; - } const result = await formAction(this.rpc(), { action: 'set-value', formId: this.form().id, path: this.node().path, - value, + value: this.draft(), + coerce: true, mode: 'user', }); this.message.set(actionMessage(result)); diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts index 5f62e48..f8d9de7 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; import { FormsFieldDetail } from './forms-field-detail'; import { FormsLint, FormsSubmit } from './forms-report'; import { FormsTimeline } from './forms-timeline'; @@ -20,6 +21,7 @@ import { SOURCE_LABELS, actionMessage, formAction, + pageOf, type CollectedForm, type FormEvent, type FormFieldError, @@ -75,7 +77,8 @@ function countErrors(node: FormFieldNode): number { } function countFields(node: FormFieldNode): number { - return 1 + (node.children ?? []).reduce((sum, c) => sum + countFields(c), 0); + if (!node.children) return node.type === 'control' && node.materialized !== false ? 1 : 0; + return node.children.reduce((sum, c) => sum + countFields(c), 0); } @Component({ @@ -83,47 +86,101 @@ function countFields(node: FormFieldNode): number { imports: [JsonPipe, FormsFieldDetail, FormsTimeline, FormsSubmit, FormsLint], template: ` @if (!rpc()) { -

Connecting…

+
+ +

Connecting to the devtools server…

+
} @else if (failed()) { -

Could not load forms from the devtools server. Reload to try again.

+ } @else if (loading()) { -

Loading forms…

+
+ +

Loading forms…

+
} @else if (!forms().length) {
-

No forms on the page yet.

+

No forms on the page yet

Open a page that renders a form. Signal Forms, reactive and template-driven forms all show up here, in development builds.

+ } @else if (!visible().length) { +
+

No forms on this page

+

+ Other connected tabs report {{ forms().length }} + {{ forms().length === 1 ? 'form' : 'forms' }}. +

+ +
} @else {
-
    - @for (form of forms(); track form.id) { -
  • - -
  • +
+
    + @for (form of visible(); track form.id) { +
  • + +
  • + } +
+
- @if (selected(); as form) { -
+ @if (missing(); as label) { +
+

{{ label }}

+

+ This form is no longer on the page. It comes back here if the page renders it again. +

+ +
+ } @else if (selected(); as form) { +
+
+

{{ form.label }}

+

+ {{ kindLabel(form.kind) }} · {{ form.id }} +

+
{{ form.root.status @@ -136,33 +193,69 @@ function countFields(node: FormFieldNode): number { @if (form.root.submitting) { submitting } - {{ counts().get(form.id)?.fields }} fields, - {{ counts().get(form.id)?.errors }} errors{{ counts().get(form.id)?.fields }} fields · + {{ + counts().get(form.id)?.errors + }} + errors
+ @if (form.errorSummary?.length) { +
+ Error summary ({{ form.errorSummary!.length }}) +
    + @for (entry of form.errorSummary!; track $index) { +
  • + {{ entry.path || '(form)' }} {{ entry.message }} + {{ entry.kind }} +
  • + } +
+
+ } -
- - - - - - @if (snapshot()) { - + + - } - - + + + + @if (snapshot()) { + + } + + + +
+

{{ message() }}

-

{{ message() }}

@for (tab of tabs; track tab.id) { @@ -187,27 +280,31 @@ function countFields(node: FormFieldNode): number { > @switch (tab_()) { @case ('fields') { -
- Show only fields that are - @for (chip of chips; track chip.id) { - - } -
- +
+ +
+ Show only fields that are + @for (chip of chips; track chip.id) { + + } +
+
@@ -227,7 +324,11 @@ function countFields(node: FormFieldNode): number { (mouseenter)="highlight(form.id, row.node.path)" (mouseleave)="highlight(null, '')" > - - @for (row of rows(); track $index) { - - + @@ -138,7 +152,7 @@ interface OutletRow {

Outlets

    @for (row of outletRows(); track $index) { -
  • +
  • {{ row.outlet.outlet }} @if (row.outlet.activated) { {{ row.outlet.component ?? '?' }} for @@ -157,71 +171,125 @@ interface OutletRow {
} } @else { -

This page reports no Router.

+
+

This page reports no Router.

+

+ Add provideRouter() to the app config and navigate once to see the active + routes here. +

+
} `, styles: ` ${SHARED_STYLES} :host { display: grid; + gap: 16px; + min-width: 0; + } + .url { + display: flex; + align-items: baseline; gap: 12px; + min-width: 0; + margin: 0; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + } + .url-label { + flex: none; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .url code { - font-size: 14px; + min-width: 0; color: var(--accent); - } - .note { - margin: 0; - padding: 8px 10px; - border-left: 3px solid #fef08a; - background: #27272a; - color: #e4e4e7; - font-size: 13px; + font-size: 14px; + font-weight: 500; } .pending { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 8px 12px; align-items: center; + padding: 6px 6px 6px 14px; + border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 10%, transparent); + color: var(--warn); font-size: 13px; - color: #fef08a; } - .facts { - display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 12px; - margin: 0; - font-size: 13px; + .pending-text { + flex: 1 1 200px; + min-width: 0; } - dt { - color: #a1a1aa; + .pending code { + color: var(--text-strong); } - dd { - margin: 0; - color: #e4e4e7; + .pulse { + flex: none; + width: 8px; + height: 8px; + border-radius: 99px; + background: var(--warn); + animation: pulse 1.2s ease-in-out infinite; } - .path { - font-family: monospace; - color: var(--accent); - white-space: nowrap; + @keyframes pulse { + 50% { + opacity: 0.35; + } + } + @media (prefers-reduced-motion: reduce) { + .pulse { + animation: none; + } } .sub { - font-family: inherit; - color: #a1a1aa; + margin-top: 4px; + color: var(--text-2); + font-family: var(--font-sans); font-size: 12px; white-space: normal; } + .component { + color: var(--text-strong); + } + .entry + .entry { + margin-top: 2px; + } .data { max-width: 360px; } .outlets { - list-style: none; - margin: 0; - padding: 0; display: grid; - gap: 6px; + gap: 2px; + margin: 0; + padding: 6px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); font-size: 13px; - color: #e4e4e7; + list-style: none; + animation: enter 0.35s var(--ease) both; + } + .outlets li { + padding-top: 6px; + padding-right: 10px; + padding-bottom: 6px; + border-radius: var(--radius-sm); + line-height: 1.8; + overflow-wrap: anywhere; + transition: background-color 0.15s var(--ease); + } + .outlets li:hover { + background: var(--surface-2); } `, }) diff --git a/app/src/pages/route-inspector.ts b/app/src/pages/route-inspector.ts index 23f523a..b650e93 100644 --- a/app/src/pages/route-inspector.ts +++ b/app/src/pages/route-inspector.ts @@ -1,156 +1,227 @@ -import { Component, input, signal, effect, computed } from '@angular/core'; +import { Component, computed, effect, input, linkedSignal, signal, viewChild } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; import { LiveRoute } from './live-route'; - -interface RouteInfo { - path: string; - component?: string; - redirectTo?: string; - title?: string; - hasChildren: boolean; - file: string; -} +import { SHARED_STYLES, sourceLocation, type SourceRoute } from './router-types'; @Component({ selector: 'app-route-inspector', imports: [LiveRoute], template: ` - + -

Route config

-
- - -
+
+
+

Source route config

+ + Read from your source files. + @if (liveHasConfig()) { + The Routes tab above shows what the router uses. + } + + @if (liveHasConfig()) { + + } +
+
+
+ + +
- @if (loading()) { -

Scanning routes…

- } @else if (filtered().length === 0) { -

No routes found.

- } @else { -
+ + @if (row.node.materialized === false) { not created yet } @else { @@ -316,6 +418,12 @@ function countFields(node: FormFieldNode): number { @if (row.node.accessor) { {{ row.node.accessor }} } + @if (row.node.name) { + name {{ row.node.name }} + } + @for (value of row.node.metadata ?? []; track $index) { + metadata {{ value | json }} + } @for (reason of row.node.disabledReasons ?? []; track $index) { disabled: {{ reason }} } @@ -325,6 +433,9 @@ function countFields(node: FormFieldNode): number {
{{ errorText(row.node, error) }} {{ error.kind }} + @if (error.kind === 'standardSchema' && error.params?.['path']) { + at {{ error.params!['path'] }} + } @if (error.source) { {{ sourceText(error) }} } @@ -339,8 +450,8 @@ function countFields(node: FormFieldNode): number {
{{ row.node.truncated }} more fields under {{ row.node.path || 'the form' }} not shown @@ -349,7 +460,7 @@ function countFields(node: FormFieldNode): number { } } @empty {
+ @if (filter()) { No field path matches "{{ filter() }}". } @else { @@ -393,78 +504,47 @@ function countFields(node: FormFieldNode): number { `, styles: ` ${FORMS_STYLES} - .actions { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .tabs { - display: flex; - gap: 4px; - border-bottom: 1px solid #27272a; + :host { + display: block; } - .tabs [role='tab'] { - padding: 6px 12px; - border: none; - border-bottom: 2px solid transparent; - background: none; - color: #d4d4d8; - font: inherit; - font-size: 13px; - cursor: pointer; - } - .tabs [role='tab'][aria-selected='true'] { - border-bottom-color: var(--accent); - color: #fafafa; - } - .tabs [role='tab']:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - .panel { + .layout { display: grid; - gap: 12px; - } - .chips { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin: 0; - padding: 0; - border: 0; - color: #d4d4d8; - font-size: 13px; - } - .field[aria-pressed='true'] { - color: var(--accent); - text-decoration: underline; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); + gap: 16px; + align-items: start; } - .flags span.warn { - border-color: #a16207; - color: #fef08a; + .sidebar { + min-width: 0; + padding: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + animation: enter 0.35s var(--ease) both; } - .source { - margin-left: 6px; - color: #a1a1aa; + .list-heading { + display: flex; + gap: 8px; + align-items: center; + margin: 4px 8px 8px; + color: var(--text-3); font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } - .unseen { - color: #fde68a !important; + .list-count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); font-size: 11px; - } - .layout { - display: grid; - grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); - gap: 16px; - } - @media (max-width: 720px) { - .layout { - grid-template-columns: 1fr; - } + letter-spacing: 0; + line-height: 18px; + font-variant-numeric: tabular-nums; } .form-list { display: grid; - gap: 4px; + gap: 2px; align-content: start; margin: 0; padding: 0; @@ -473,187 +553,636 @@ function countFields(node: FormFieldNode): number { .form-item { width: 100%; display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: 8px minmax(0, 1fr) auto; grid-template-areas: 'dot label count' '. kind kind'; - gap: 2px 8px; + gap: 2px 10px; align-items: center; padding: 8px 10px; - border: 1px solid #27272a; - border-radius: 6px; + border: 0; + border-radius: var(--radius-sm); background: transparent; - color: #e4e4e7; + color: var(--text); + font: inherit; text-align: left; cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .form-item:hover { + background: var(--surface-2); + } + .form-item:active { + background: var(--surface-3); } .form-item.active { - border-color: var(--accent); - background: #18181b; + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + color: var(--text-strong); + } + .form-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .form-item .dot { grid-area: dot; } .form-item .label { grid-area: label; - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; font-size: 13px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; } .form-item .kind { grid-area: kind; - color: #a1a1aa; + min-width: 0; + overflow: hidden; + color: var(--text-3); font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .form-item .id { + font-family: var(--font-mono); + font-size: 11.5px; + } + .form-item.active .kind { + color: var(--text-2); } .form-item .count { grid-area: count; - padding: 0 6px; - border-radius: 999px; - background: #7f1d1d; - color: #fecaca; - font-size: 12px; + min-width: 20px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + font-variant-numeric: tabular-nums; } .dot { width: 8px; height: 8px; border-radius: 50%; - background: #22c55e; + background: var(--ok); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 15%, transparent); } .dot[data-status='INVALID'] { - background: #ef4444; + background: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 15%, transparent); } .dot[data-status='PENDING'] { - background: #eab308; + background: var(--warn); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 15%, transparent); } .dot[data-status='DISABLED'] { - background: #71717a; + background: var(--text-3); + box-shadow: none; } .detail { display: grid; - gap: 12px; + gap: 16px; + min-width: 0; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + animation: enter 0.35s var(--ease) both; + } + .detail-head { + display: grid; + gap: 4px; + min-width: 0; + } + .detail-head h2 { + margin: 0; + color: var(--text-strong); + font-size: 16px; + font-weight: 600; + line-height: 1.3; + overflow-wrap: anywhere; + } + .detail-meta { + margin: 0; + color: var(--text-3); + font-size: 12px; + overflow-wrap: anywhere; + } + .detail-meta code { + color: var(--text-2); + font-size: 11.5px; + } + .gone { + justify-items: start; + } + .gone h2 { + margin: 0; + color: var(--text-strong); + font-size: 16px; + font-weight: 600; + overflow-wrap: anywhere; + } + .gone p { + margin: 0; + } + .page-toggle { + display: flex; + gap: 8px; + align-items: center; + min-height: 32px; + margin: 0 8px 8px; + color: var(--text-2); + font-size: 13px; + cursor: pointer; + } + .page-toggle input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); + } + .page-toggle input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .error-summary { min-width: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + font-size: 13px; + } + .error-summary summary { + color: var(--text); + font-weight: 500; + cursor: pointer; + } + .error-summary summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .error-summary ul { + display: grid; + gap: 4px; + margin: 8px 0 0; + padding: 0 0 0 16px; + color: var(--text); + overflow-wrap: anywhere; + } + .error-summary .kind-tag { + margin-left: 6px; + color: var(--text-2); + font-size: 11px; } .summary { display: flex; flex-wrap: wrap; - gap: 8px 14px; + gap: 6px; align-items: center; - color: #d4d4d8; - font-size: 13px; + color: var(--text-2); + font-size: 12px; + } + .summary > span:not(.badge):not(.totals) { + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text); + line-height: 22px; + } + .totals { + margin-left: auto; + color: var(--text-3); + font-variant-numeric: tabular-nums; + } + .totals b { + color: var(--text); + font-weight: 600; + } + .totals b.has-errors { + color: var(--danger); } .badge { - padding: 1px 6px; - border-radius: 4px; - background: #14532d; - color: #bbf7d0; + display: inline-block; + padding: 0 8px; + border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); font-size: 11px; font-weight: 600; + letter-spacing: 0.04em; + line-height: 20px; + white-space: nowrap; + } + .summary > .badge { + line-height: 22px; } .badge[data-status='INVALID'] { - background: #7f1d1d; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } .badge[data-status='PENDING'] { - background: #713f12; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .badge[data-status='DISABLED'] { - background: #3f3f46; - color: #e4e4e7; + border-color: var(--border-strong); + background: var(--surface-2); + color: var(--text-2); } - .filter { - padding: 8px 12px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; + .action-bar { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); } - .filter:focus-visible, - .form-item:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; + .actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; } - .table-scroll { + .divider { + width: 1px; + height: 20px; + background: var(--border-strong); + } + .spacer { + flex: 1 1 0; + } + .actions .small:not(.primary):not(.danger) { + max-width: 240px; + } + .status:empty { + height: 0; + margin-top: -8px; + } + .tabs { + display: inline-flex; + justify-self: start; + gap: 2px; + max-width: 100%; + height: 34px; + padding: 2px; overflow-x: auto; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); } - .table-scroll:focus-visible, - .field:focus-visible { + .tabs [role='tab'] { + flex: none; + height: 28px; + padding: 0 14px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text-2); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .tabs [role='tab']:hover { + color: var(--text); + background: var(--surface-2); + } + .tabs [role='tab'][aria-selected='true'] { + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); + } + .tabs [role='tab']:focus-visible { outline: 2px solid var(--accent); - outline-offset: 2px; + outline-offset: -2px; } - .field { + .panel { + display: grid; + gap: 12px; + min-width: 0; + animation: enter 0.35s var(--ease) both; + } + .filters { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + margin: 0; padding: 0; - border: none; - background: none; - color: inherit; - font: inherit; + border: 0; + } + .chips label { + position: relative; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); + font-size: 12px; + font-weight: 500; + white-space: nowrap; cursor: pointer; + user-select: none; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease), + color 150ms var(--ease); + } + .chips label:hover { + color: var(--text); + border-color: var(--border-strong); } - .sr-only { + .chips input { position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + } + .chips label:has(input:checked) { + border-color: var(--accent-line); + background: var(--accent-soft); + color: var(--accent); + } + .chips label:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .filter { + flex: 1 1 220px; + min-width: 0; + height: 34px; + padding: 0 12px 0 34px; + background-color: var(--bg); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238e8e99' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m20 20-3.5-3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 12px center; + background-size: 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font: inherit; + font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .filter::placeholder { + color: var(--text-3); + } + .filter:hover { + border-color: color-mix(in srgb, var(--text-3) 55%, var(--border-strong)); + } + .filter:focus, + .filter:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .table-scroll { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .table-scroll:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .fields { width: 100%; + min-width: 680px; border-collapse: collapse; font-size: 13px; } .fields th, .fields td { - padding: 6px 8px; - border-bottom: 1px solid #27272a; + padding: 8px 12px; + border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; + line-height: 20px; + } + .fields tbody tr:last-child > * { + border-bottom: 0; } .fields thead th { - color: #a1a1aa; - font-weight: 500; + background: var(--surface-2); + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + line-height: 16px; + text-transform: uppercase; + white-space: nowrap; } .fields tbody th { - color: #e4e4e7; + color: var(--text); font-weight: 500; white-space: nowrap; } + .fields tbody tr { + transition: background-color 150ms var(--ease); + } .fields tbody tr:hover { - background: #18181b; + background: var(--surface-2); + } + .fields tbody tr.invalid > th { + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--danger) 60%, transparent); + } + .fields tbody tr:has(.field[aria-pressed='true']) { + background: var(--accent-soft); + } + .fields tbody tr:has(.field[aria-pressed='true']) > th { + box-shadow: inset 2px 0 0 var(--accent); + color: var(--text-strong); + } + .name { + max-width: 280px; + } + .field { + display: inline-block; + max-width: 200px; + padding: 0; + overflow: hidden; + border: none; + border-radius: 4px; + background: none; + color: inherit; + font-family: var(--font-mono); + font-size: 12.5px; + font-weight: 500; + line-height: 20px; + text-align: left; + text-overflow: ellipsis; + vertical-align: top; + white-space: nowrap; + cursor: pointer; + transition: color 150ms var(--ease); + } + .field:hover { + color: var(--accent-hover); + text-decoration: underline; + text-underline-offset: 3px; + } + .field[aria-pressed='true'] { + color: var(--accent); + } + .field:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .type { - margin-left: 6px; - color: #a1a1aa; + margin-left: 8px; + color: var(--text-3); font-size: 11px; font-weight: 400; + vertical-align: top; + } + .value { + min-width: 140px; + max-width: 320px; } .value code, .errors code { - color: #c4b5fd; + color: #fde68a; overflow-wrap: anywhere; } + .value .muted { + margin-top: 2px; + font-size: 12px; + } + .status-cell { + white-space: nowrap; + } + .flags { + min-width: 160px; + } .flags span { display: inline-block; - margin: 0 4px 2px 0; - padding: 0 5px; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #d4d4d8; + margin: 0 4px 4px 0; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); font-size: 11px; + line-height: 18px; + white-space: nowrap; + } + .flags span.warn { + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); + } + .errors { + min-width: 180px; } .errors div { - color: #fca5a5; + color: var(--danger); + overflow-wrap: anywhere; + } + .errors div + div { + margin-top: 4px; + } + .errors .kind-tag { + margin-left: 6px; + color: var(--text-3); + font-size: 11px; } - .kind-tag { + .source { margin-left: 6px; - color: #a1a1aa; + color: var(--text-2); + font-size: 11px; + } + .errors .unseen { + color: var(--warn); font-size: 11px; } - .muted { - color: #a1a1aa; + .truncated { + color: var(--text-3); + font-size: 12px; + font-style: italic; + } + .fields .no-match { + padding: 24px 12px; + color: var(--text-2); + text-align: center; } .empty { - padding: 32px; + display: grid; + justify-items: center; + gap: 8px; + margin: 0; + padding: 48px 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-2); + font-size: 13px; + line-height: 1.5; text-align: center; - color: #d4d4d8; + animation: enter 0.35s var(--ease) both; + } + .empty p { + max-width: 52ch; + margin: 0; + } + .empty .small { + margin-top: 8px; + } + .empty-title { + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + } + @media (max-width: 720px) { + .layout { + grid-template-columns: minmax(0, 1fr); + } + .form-list { + max-height: 240px; + overflow-y: auto; + } + } + @media (max-width: 480px) { + .detail { + padding: 12px; + } + .totals { + flex-basis: 100%; + margin-left: 0; + } + .spacer, + .divider { + display: none; + } } `, }) @@ -667,6 +1196,15 @@ export class FormsInspector { readonly loading = signal(true); readonly failed = signal(false); readonly selectedId = signal(null); + readonly selectedLabel = signal(null); + readonly hostPageId = hostPageId(); + readonly allPages = signal(false); + readonly visible = computed(() => { + const forms = this.forms(); + const page = this.hostPageId; + return page && !this.allPages() ? forms.filter((f) => pageOf(f.id) === page) : forms; + }); + readonly otherPages = computed(() => this.forms().some((f) => pageOf(f.id) !== this.hostPageId)); readonly instrumented = signal([]); readonly recording = computed(() => { const id = this.selected()?.id ?? ''; @@ -689,7 +1227,7 @@ export class FormsInspector { readonly counts = computed( () => new Map( - this.forms().map((form) => [ + this.visible().map((form) => [ form.id, { fields: countFields(form.root), errors: countErrors(form.root) }, ]), @@ -697,10 +1235,25 @@ export class FormsInspector { ); readonly selected = computed(() => { - const forms = this.forms(); - return forms.find((f) => f.id === this.selectedId()) ?? forms[0] ?? null; + const forms = this.visible(); + const id = this.selectedId(); + if (id === null) return forms[0] ?? null; + const label = this.selectedLabel(); + const page = pageOf(id); + return ( + forms.find((f) => f.id === id) ?? + forms.find((f) => f.label === label && pageOf(f.id) === page) ?? + forms.find((f) => f.label === label) ?? + null + ); }); + readonly missing = computed(() => + this.selectedId() !== null && !this.selected() && this.visible().length + ? (this.selectedLabel() ?? this.selectedId()) + : null, + ); + readonly rows = computed(() => { const form = this.selected(); if (!form) return []; @@ -778,6 +1331,11 @@ export class FormsInspector { } } + retry() { + const client = this.rpc(); + if (client) void this.load(client); + } + async pick() { const form = this.selected(); if (!form) return; @@ -806,7 +1364,9 @@ export class FormsInspector { } selectForm(id: string) { + if (this.hostPageId && pageOf(id) && pageOf(id) !== this.hostPageId) this.allPages.set(true); this.selectedId.set(id); + this.selectedLabel.set(this.forms().find((f) => f.id === id)?.label ?? null); this.filter.set(''); this.fieldPath.set(null); this.snapshot.set(null); diff --git a/app/src/pages/forms-report.ts b/app/src/pages/forms-report.ts index ba3ca64..ccb7591 100644 --- a/app/src/pages/forms-report.ts +++ b/app/src/pages/forms-report.ts @@ -5,11 +5,19 @@ import { FORMS_STYLES, formsCall, plain, type FormLintFinding } from './forms-ty @Component({ selector: 'app-forms-submit', template: ` -

Submit

-
{{ submit() || 'Loading…' }}
-

Payload

-
{{ payload() || 'Loading…' }}
-
+
+ +
{{
+        submit() || 'Loading…'
+      }}
+
+
+ +
{{
+        payload() || 'Loading…'
+      }}
+
+
{{ message() }}
@@ -17,16 +25,25 @@ import { FORMS_STYLES, formsCall, plain, type FormLintFinding } from './forms-ty styles: ` ${FORMS_STYLES} :host { + display: grid; + gap: 16px; + min-width: 0; + animation: enter 0.35s var(--ease) both; + } + .block { display: grid; gap: 8px; + min-width: 0; } - h3 { - margin: 4px 0 0; - color: #d4d4d8; - font-size: 13px; + .explain { + max-height: 420px; + overflow: auto; } - .status { - margin-left: 8px; + .fixture { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; } `, }) @@ -79,26 +96,54 @@ export class FormsSubmit { selector: 'app-forms-lint', template: ` @if (findings() === null) { -

Checking…

+
+ +

Checking the form for problems…

+
} @else if (!findings()!.length) { -

No problems found. For generic accessibility, run axe on the page.

+
+ +

No problems found

+

For generic accessibility checks, run axe on the page.

+
} @else { +

+ {{ findings()!.length }} + {{ findings()!.length === 1 ? 'finding' : 'findings' }} +

    @for (f of findings(); track $index) { -
  • - {{ f.severity }} - {{ f.rule }} - @if (f.path) { - at {{ f.path }} - } -
    {{ f.message }}
    -
    Fix: {{ f.fix }}
    +
  • +
    + {{ f.severity }} + {{ f.rule }} + @if (f.path) { + at {{ f.path }} + } +
    +

    {{ f.message }}

    +

    Fix {{ f.fix }}

  • }
@@ -106,22 +151,101 @@ export class FormsSubmit { `, styles: ` ${FORMS_STYLES} + :host { + display: grid; + gap: 12px; + min-width: 0; + } + .ok-icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin-bottom: 4px; + border-radius: 50%; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); + } + .summary { + margin: 0; + color: var(--text-2); + font-size: 12.5px; + } + .tabular { + color: var(--text-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; + } .findings { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; + color: var(--text); font-size: 13px; - color: #e4e4e7; + animation: enter 0.35s var(--ease) both; } .findings li { - padding: 8px; - border: 1px solid #27272a; - border-radius: 6px; + display: grid; + gap: 6px; + min-width: 0; + padding: 12px 16px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: inset 3px 0 0 var(--border-strong); + transition: border-color 150ms var(--ease); + } + .findings li[data-severity='error'] { + box-shadow: inset 3px 0 0 var(--danger); + } + .findings li[data-severity='warning'] { + box-shadow: inset 3px 0 0 var(--warn); + } + .findings li:hover { + border-color: var(--border-strong); + } + .meta { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + min-width: 0; + } + .severity { + text-transform: uppercase; + letter-spacing: 0.06em; + } + .rule { + color: var(--text-strong); + font-weight: 600; + overflow-wrap: anywhere; + } + .path { + font-size: 12.5px; + overflow-wrap: anywhere; + } + .path code { + color: #fde68a; + } + .message, + .fix { + margin: 0; + line-height: 1.5; + overflow-wrap: anywhere; + } + .fix { + color: var(--text-2); + font-size: 12.5px; } - code { - color: #c4b5fd; + .fix-label { + margin-right: 4px; + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; } `, }) diff --git a/app/src/pages/forms-timeline.ts b/app/src/pages/forms-timeline.ts index 0bbcd55..f41d99a 100644 --- a/app/src/pages/forms-timeline.ts +++ b/app/src/pages/forms-timeline.ts @@ -6,137 +6,329 @@ const ORIGINS = ['all', 'user', 'code', 'devtools'] as const; @Component({ selector: 'app-forms-timeline', template: ` - -
- Show changes from - @for (origin of origins; track origin) { - - } -
+
+ +
+ Show changes from + @for (origin of origins; track origin) { + + } +
+
@if (shown().length) {
    @for (event of shown(); track event.formId + '#' + event.seq) {
  1. - - {{ event.path || '(form)' }} - {{ event.type }} - @if (event.outcome) { - {{ - event.outcome - }} - } - - @if (event.prev !== undefined) { - {{ event.prev }} → + +
    +
    + {{ event.path || '(form)' }} + {{ event.type }} + @if (event.outcome) { + {{ + event.outcome + }} + } + @if (event.count && event.count > 1) { + ×{{ event.count }} + } + @if (event.origin) { + {{ event.origin }} + } + @if (event.ms !== undefined) { + pending {{ event.ms }}ms + } + @if (event.renders) { + {{ event.renders }} renders: {{ (event.rendered ?? []).join(', ') }} + } +
    + @if (event.prev !== undefined || event.detail) { +
    + @if (event.prev !== undefined) { + {{ event.prev }} + changed to + } + {{ event.detail }} +
    } - {{ event.detail }} - - @if (event.count && event.count > 1) { - ×{{ event.count }} - } - @if (event.origin) { - {{ event.origin }} - } - @if (event.ms !== undefined) { - pending {{ event.ms }}ms - } - @if (event.renders) { - {{ event.renders }} renders: {{ (event.rendered ?? []).join(', ') }} - } - @if (event.caller) { - from {{ event.caller }} - } + @if (event.caller) { + from {{ event.caller }} + } +
  2. }
+ } @else if (events().length) { +
+

No changes from {{ filter() }}

+

Pick another source above, or interact with the form to record more.

+
} @else { -

No changes yet. Type into the form to see them here.

+
+

No changes yet

+

Type into the form to see them here.

+
} `, styles: ` ${FORMS_STYLES} :host { display: grid; - gap: 8px; + gap: 12px; + min-width: 0; } - .chips { + .toolbar { display: flex; flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + } + .record { + display: inline-flex; + flex: 1 1 260px; gap: 10px; - margin: 0; - padding: 0; - border: 0; - color: #d4d4d8; + align-items: flex-start; + min-width: 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + cursor: pointer; + transition: + border-color 150ms var(--ease), + background-color 150ms var(--ease); + } + .record:hover { + border-color: var(--border-strong); + } + .record.on { + border-color: var(--accent-line); + background: var(--accent-soft); + } + .record:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .record input { + flex: none; + width: 16px; + height: 16px; + margin: 2px 0 0; + accent-color: var(--accent); + cursor: pointer; + } + .record input:focus-visible { + outline: none; + } + .record-text { + display: grid; + gap: 2px; + min-width: 0; + } + .record-title { + display: inline-flex; + gap: 6px; + align-items: center; + color: var(--text-strong); font-size: 13px; + font-weight: 500; + } + .record-hint { + color: var(--text-2); + font-size: 12px; + line-height: 1.4; + } + .rec-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 20%, transparent); + animation: rec-pulse 1.6s ease-in-out infinite; + } + @keyframes rec-pulse { + 50% { + opacity: 0.45; + } + } + .chips { + display: inline-flex; + flex: none; + gap: 2px; + min-width: 0; + height: 34px; + margin: 0; + padding: 2px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + } + .chips label { + position: relative; + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 12px; + border-radius: 7px; + color: var(--text-2); + font-size: 12.5px; + font-weight: 500; + text-transform: capitalize; + cursor: pointer; + user-select: none; + transition: + background-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .chips label:hover { + color: var(--text); + background: var(--surface-2); } - .sr-only { + .chips input { position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + } + .chips label:has(input:checked) { + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); + } + .chips label:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; } .events { display: grid; - gap: 4px; + gap: 2px; margin: 0; - padding: 0; + padding: 4px; list-style: none; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); font-size: 13px; + animation: enter 0.35s var(--ease) both; } .events li { - display: flex; - flex-wrap: wrap; - gap: 8px; + display: grid; + grid-template-columns: 11ch minmax(0, 1fr); + gap: 12px; align-items: baseline; - color: #d4d4d8; + padding: 8px 12px; + border-radius: 6px; + color: var(--text); + transition: background-color 150ms var(--ease); + } + .events li:hover { + background: var(--surface-3); } .events li[data-type='submit'] { - border-left: 3px solid var(--accent); - padding-left: 6px; + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + color: var(--text-strong); } time { - color: #a1a1aa; + color: var(--text-3); + font-family: var(--font-mono); + font-size: 12px; font-variant-numeric: tabular-nums; + white-space: nowrap; } - code { - color: #c4b5fd; + .body { + display: grid; + gap: 4px; + min-width: 0; + } + .line { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: center; + min-width: 0; + } + .path { + color: #fde68a; + overflow-wrap: anywhere; } .event-type { - color: #93c5fd; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + } + .tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; } .detail { + color: var(--text); + font-family: var(--font-mono); + font-size: 12.5px; overflow-wrap: anywhere; } - .record { - color: #d4d4d8; - font-size: 13px; + .prev { + color: var(--text-2); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--text-2) 50%, transparent); + } + .arrow { + margin: 0 6px; + color: var(--text-3); } .caller { - flex-basis: 100%; - padding-left: 16px; - color: #a1a1aa; - font-family: ui-monospace, monospace; + color: var(--text-3); + font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; } + @media (max-width: 480px) { + .events li { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + .chips { + width: 100%; + } + .chips label { + flex: 1; + justify-content: center; + padding: 0 8px; + } + } `, }) export class FormsTimeline { @@ -155,6 +347,10 @@ export class FormsTimeline { .reverse(); }); + iso(timestamp: number) { + return new Date(timestamp).toISOString(); + } + time(timestamp: number) { return new Date(timestamp).toLocaleTimeString(); } diff --git a/app/src/pages/forms-types.ts b/app/src/pages/forms-types.ts index 386354c..7d7a174 100644 --- a/app/src/pages/forms-types.ts +++ b/app/src/pages/forms-types.ts @@ -5,6 +5,7 @@ export type FieldStatus = 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'; export interface FormFieldError { kind: string; message: string; + params?: Record; source?: string; from?: string; } @@ -39,6 +40,8 @@ export interface FormFieldNode { changed?: boolean; redacted?: string; pendingSince?: number; + name?: string; + metadata?: unknown[]; dom?: { errorShown?: boolean; drift?: string | true; labelled?: boolean }; } @@ -51,9 +54,14 @@ export interface CollectedForm { submitted?: boolean; submit?: { hasAction: boolean; willRun: boolean; submitting: boolean }; submitDom?: { reasons: string[] }; + errorSummary?: { path: string; kind: string; message: string }[]; root: FormFieldNode; } +export function pageOf(formId: string): string { + return formId.split('@')[1] ?? ''; +} + export interface FormEvent { formId: string; path: string; @@ -148,65 +156,207 @@ export function plain(text: string | null): string { export const FORMS_STYLES = ` .muted { - color: #a1a1aa; + color: var(--text-2); + } + .sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + code { + font-family: var(--font-mono); + font-size: 12.5px; } .small { - padding: 4px 10px; - border: 1px solid #52525b; - border-radius: 6px; - background: #18181b; - color: #e4e4e7; - font-size: 12px; + display: inline-block; + max-width: 100%; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 32px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease), + color 150ms var(--ease), + box-shadow 150ms var(--ease); } .small:hover { - border-color: var(--accent); + background: var(--surface-3); + border-color: color-mix(in srgb, var(--text-3) 55%, var(--border-strong)); } - .small:focus-visible, - .field-input:focus-visible { + .small:active { + background: var(--border-strong); + } + .small:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + .small:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .small.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + .small.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + } + .small.danger { + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + color: var(--danger); + } + .small.danger:hover { + background: color-mix(in srgb, var(--danger) 12%, transparent); + border-color: color-mix(in srgb, var(--danger) 55%, transparent); + } + .small.armed { + box-shadow: 0 0 0 3px var(--accent-soft); + } + .small.danger.armed { + background: var(--danger); + border-color: var(--danger); + color: #1f0707; + font-weight: 600; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); + } .field-input { - padding: 4px 8px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; + min-width: 0; + height: 34px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); + } + .field-input::placeholder { + color: var(--text-3); + font-family: var(--font-sans); + } + .field-input:hover { + border-color: color-mix(in srgb, var(--text-3) 55%, var(--border-strong)); + } + .field-input:focus, + .field-input:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } .explain { margin: 0; - padding: 10px; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 12px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; - font-family: ui-monospace, monospace; + tab-size: 2; + } + .explain[aria-busy='true'] { + color: var(--text-3); + } + .section-label { + margin: 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .tag { display: inline-block; - margin: 0 4px 2px 0; - padding: 0 5px; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #d4d4d8; + margin: 0; + padding: 1px 8px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--surface-2); + color: var(--text-2); font-size: 11px; + font-weight: 500; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .tag[data-tone='ok'] { + border-color: color-mix(in srgb, var(--ok) 30%, transparent); + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .tag[data-tone='warn'] { - border-color: #a16207; - color: #fef08a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); } .tag[data-tone='bad'] { - border-color: #b91c1c; - color: #fecaca; + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); } .status { - min-height: 1.2em; margin: 0; - color: #d4d4d8; + color: var(--text-2); + font-size: 12.5px; + line-height: 1.5; + overflow-wrap: anywhere; + } + .empty-state { + display: grid; + justify-items: center; + gap: 6px; + margin: 0; + padding: 40px 24px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text-2); font-size: 13px; + line-height: 1.5; + text-align: center; + } + .empty-state p { + max-width: 52ch; + margin: 0; + } + .empty-state .empty-title { + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: forms-spin 0.8s linear infinite; + } + @keyframes forms-spin { + to { + transform: rotate(360deg); + } } `; diff --git a/app/src/pages/live-route.ts b/app/src/pages/live-route.ts index 4bdddc8..de3ff76 100644 --- a/app/src/pages/live-route.ts +++ b/app/src/pages/live-route.ts @@ -9,12 +9,14 @@ import { signal, } from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; import { RouteCurrent } from './route-current'; import { RouteLint } from './route-lint'; import { RouteSetup } from './route-setup'; import { RouteTimeline } from './route-timeline'; import { RouteTree } from './route-tree'; -import type { RouterPage } from './router-types'; +import type { RouterPage, SourceRoute } from './router-types'; +import { Select } from '../ui/select'; const TABS = [ { id: 'current', label: 'Current' }, @@ -28,25 +30,35 @@ type TabId = (typeof TABS)[number]['id']; @Component({ selector: 'app-live-route', - imports: [RouteCurrent, RouteLint, RouteSetup, RouteTimeline, RouteTree], + imports: [RouteCurrent, RouteLint, RouteSetup, RouteTimeline, RouteTree, Select], template: ` +
+

Live router

+ @if (page(); as current) { + @if (pages().length > 1) { +
+ Page + +
+ } + } +
@if (failed()) { -

Could not load the live router state.

+ } @else if (loading()) { -

Loading the live router state…

+

Loading the live router state…

} @else if (page(); as current) { - @if (pages().length > 1) { - - }
@for (tab of tabs; track tab.id) { } @@ -81,7 +92,7 @@ type TabId = (typeof TABS)[number]['id']; } @case ('routes') { - + } @case ('setup') { @@ -92,76 +103,183 @@ type TabId = (typeof TABS)[number]['id']; }
} @else { -

No page is reporting router state yet. Open the app in a browser.

+
+

No page is reporting router state yet.

+

+ Open the app in a browser. This view updates as soon as a page connects. +

+
} `, styles: ` :host { display: grid; - gap: 12px; - margin-bottom: 28px; + gap: 16px; + min-width: 0; + margin-bottom: 32px; + } + .section-head { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + justify-content: space-between; + min-height: 34px; + } + h2 { + margin: 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .muted { - color: #a1a1aa; + color: var(--text-2); font-size: 13px; + line-height: 1.5; + } + .empty { + display: grid; + gap: 6px; + justify-items: center; + margin: 0; + padding: 40px 24px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + text-align: center; + animation: enter 0.35s var(--ease) both; + } + .empty p { + margin: 0; + max-width: 480px; + } + .empty-title { + color: var(--text-strong); + font-size: 14px; + font-weight: 600; + } + .retry { + height: 34px; + margin-top: 8px; + padding: 0 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s var(--ease); + } + .retry:hover { + background: var(--surface-3); + } + .retry:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .page-pick { display: flex; - flex-wrap: wrap; - gap: 8px; + flex: 0 1 420px; + gap: 10px; align-items: center; - font-size: 13px; - color: #d4d4d8; - } - select { - max-width: 100%; min-width: 0; - padding: 4px 8px; - background: #18181b; - border: 1px solid #52525b; - border-radius: 6px; - color: #e4e4e7; + } + .page-label { + flex: none; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .page-pick app-select { + flex: 1 1 auto; + width: 100%; } .tabs { display: flex; - flex-wrap: wrap; - gap: 4px; - border-bottom: 1px solid #27272a; + justify-self: start; + max-width: 100%; + gap: 2px; + padding: 3px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); } [role='tab'] { - padding: 6px 12px; - background: none; + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 12px; border: none; - border-bottom: 2px solid transparent; - color: #a1a1aa; - cursor: pointer; + border-radius: 7px; + background: transparent; + color: var(--text-2); + font: inherit; font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.15s var(--ease), + color 0.15s var(--ease), + box-shadow 0.15s var(--ease); + } + [role='tab']:hover { + background: var(--surface-2); + color: var(--text); } [role='tab'][aria-selected='true'] { - color: #e4e4e7; - border-bottom-color: var(--accent); + background: var(--surface-3); + color: var(--text-strong); + box-shadow: inset 0 0 0 1px var(--border-strong); } - [role='tab']:focus-visible, - select:focus-visible { + [role='tab']:focus-visible { outline: 2px solid var(--accent); - outline-offset: 2px; + outline-offset: -2px; + } + .panel { + min-width: 0; + animation: enter 0.35s var(--ease) both; } .count { - margin-left: 4px; - padding: 0 5px; - border-radius: 8px; - background: #7f1d1d; - color: #fecaca; + min-width: 18px; + padding: 0 6px; + border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); font-size: 11px; + font-weight: 600; + line-height: 16px; + text-align: center; + font-variant-numeric: tabular-nums; + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } `, }) export class LiveRoute { rpc = input(null); + sources = input([]); readonly tabs = TABS; readonly selected = signal('current'); readonly pages = signal([]); + private readonly hostPageId = hostPageId(); readonly loading = signal(true); readonly failed = signal(false); readonly pageId = linkedSignal({ @@ -169,7 +287,10 @@ export class LiveRoute { computation: (pages, previous) => previous?.value && pages.some((p) => p.pageId === previous.value) ? previous.value - : (pages.find((p) => p.snapshot)?.pageId ?? pages[0]?.pageId ?? null), + : (pages.find((p) => p.pageId === this.hostPageId && p.snapshot)?.pageId ?? + pages.find((p) => p.snapshot)?.pageId ?? + pages[0]?.pageId ?? + null), }); private unsubscribe: (() => void) | null = null; @@ -214,10 +335,19 @@ export class LiveRoute { } } - pickPage(event: Event) { - this.pageId.set((event.target as HTMLSelectElement).value); + retry() { + const client = this.rpc(); + if (client) this.load(client); } + readonly pageOptions = computed(() => + this.pages().map((p) => ({ + value: p.pageId, + label: p.snapshot?.url ?? p.pageId, + hint: p.pageId, + })), + ); + onKey(event: KeyboardEvent) { const order = this.tabs.map((tab) => tab.id); const index = order.indexOf(this.selected()); diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index f874974..7e15be6 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -1,6 +1,17 @@ -import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core'; +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + linkedSignal, + signal, +} from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; +import { hostPageId } from '../page-id'; +import { Select, type SelectOption } from '../ui/select'; type HttpSide = 'client' | 'server'; @@ -34,27 +45,39 @@ interface HttpCall { interface PayloadEntry { key: string; http?: { url?: string; status?: number; statusText?: string; responseType?: string }; + source?: 'http' | 'analog' | 'hydration'; size: number; value: unknown; } +interface HydrationMismatch { + component: string; + expected?: string; + actual?: string; +} + interface HydrationStats { enabled: boolean; hydratedComponents?: number; hydratedNodes?: number; componentsSkippedHydration?: number; deferBlocksWithIncrementalHydration?: number; + nodes?: { hydrated: number; skipped: number; mismatched: number }; + mismatches?: HydrationMismatch[]; skipHydrationHosts: string[]; warnings: string[]; + warningsCaptured?: boolean; } interface HttpPage { pageId: string; url: string; + initialUrl?: string; title: string; payload: { found: boolean; size: number; entries: PayloadEntry[]; error?: string }; hydration: HydrationStats | null; calls: HttpCall[]; + firstSeenAt?: number; reportedAt: number; } @@ -92,32 +115,42 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr @Component({ selector: 'app-network-inspector', - imports: [JsonPipe], + imports: [JsonPipe, Select], template: ` @if (loading()) { -

Loading…

+
+ + Loading… +
} @else if (failed()) { - + }
- +
+ Page + +

{{ message() }}

-

HTTP timeline ({{ timeline().length }})

+

HTTP timeline ({{ timeline().length }})

@if (timeline().length) {
@@ -126,8 +159,9 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr + - + @@ -142,23 +176,39 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr entry.side === 'server' ? 'SSR' : 'Client' }} - + - + - - + } @@ -166,54 +216,78 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr
Side Method URLPage StatusTimeTime Notes
{{ entry.method }}{{ entry.method }} - - {{ entry.status || 'ERR' }} + + {{ entry.pageUrl ?? '' }} + + {{ entry.status || 'ERR' }} {{ entry.durationMs }} ms - @if (entry.cacheHit) { - transfer cache - } - @if (entry.faulted) { - faulted - } + {{ entry.durationMs }} ms +
+ @if (entry.cacheHit) { + transfer cache + } + @if (entry.faulted) { + faulted + } +
} @else { -

- No requests yet. Add withNgDevtools() to provideHttpClient. -

+
+

No requests yet.

+

+ Add withNgDevtools() to provideHttpClient, then load a page + of the app. +

+
} @if (selectedCall(); as detail) { -

Response preview

-
{{ detail.error ?? detail.preview ?? '(no body)' }}
+
+
+

Response preview

+ +
+

+ {{ detail.method }} + {{ detail.status || 'ERR' }} + {{ detail.url }} +

+
{{
+              detail.error ?? detail.preview ?? '(no body)'
+            }}
+
}
-

Fault injection

+

Fault injection

- - +
+ Method + +
+
+ Apply on + +
-

{{ bodyError() }}

- +

{{ bodyError() }}

+ @if (draftMocksOnServer()) { +

+ SSR mocks are not transferred to the client: the browser requests the URL again and + gets the real response unless the rule also applies on the client. +

+ } +
+ +
+

Rules ({{ rules().length }})

    @for (rule of rules(); track rule.id) { -
  • +
  • - - {{ rule.target }} · {{ rule.status ?? 200 }} - @if (rule.delayMs) { - · {{ rule.delayMs }} ms - } - @if (rule.body) { - · mock body - } - + + {{ targetLabel(rule.target) }} + @if (rule.status === undefined) { + passthrough + } @else { + {{ rule.status }} + } + @if (rule.delayMs) { + {{ rule.delayMs }} ms + } + @if (rule.body) { + mock body + } + + @if (mocksOnServer(rule)) { +

    + The SSR mock is not written to TransferState, so the browser requests this URL + again. +

    + }
  • } @empty { -
  • No rules. SSR rules apply on the next page load.
  • +
  • No rules. SSR rules apply on the next page load.
  • }
-

Hydration

+

Hydration

@if (selected()?.hydration; as h) { -
-
Enabled
-
{{ h.enabled ? 'yes' : 'no (client render only)' }}
-
Hydrated components
-
{{ h.hydratedComponents ?? 'n/a' }}
-
Hydrated nodes
-
{{ h.hydratedNodes ?? 'n/a' }}
-
Skipped components
-
{{ h.componentsSkippedHydration ?? 'n/a' }}
-
Incremental defer blocks
-
{{ h.deferBlocksWithIncrementalHydration ?? 'n/a' }}
+
+
+
Enabled
+
+ {{ + h.enabled ? 'yes' : 'no (client render only)' + }} +
+
+
+
Hydrated components
+
{{ h.hydratedComponents ?? 'n/a' }}
+
+
+
Hydrated nodes
+
{{ h.hydratedNodes ?? 'n/a' }}
+
+
+
Skipped components
+
{{ h.componentsSkippedHydration ?? 'n/a' }}
+
+
+
Incremental defer blocks
+
{{ h.deferBlocksWithIncrementalHydration ?? 'n/a' }}
+
+ @if (h.nodes; as n) { +
+
DOM nodes hydrated
+
{{ n.hydrated }}
+
+
+
DOM nodes skipped
+
{{ n.skipped }}
+
+
+
Mismatched components
+
{{ n.mismatched }}
+
+ }
+ @if (h.mismatches?.length) { +

Mismatches ({{ h.mismatches!.length }})

+
    + @for (m of h.mismatches; track $index) { +
  • + {{ '<' + m.component + '>' }} + @if (m.expected) { + Expected + {{ m.expected }} + } + @if (m.actual) { + Actual {{ m.actual }} + } +
  • + } +
+ } @if (h.skipHydrationHosts.length) { -

ngSkipHydration hosts

+

ngSkipHydration hosts

    @for (host of h.skipHydrationHosts; track $index) {
  • @@ -308,262 +464,802 @@ function call(client: DevframeRpcClient | null, name: string, arg?: unknown): Pr }
} -

Warnings ({{ h.warnings.length }})

- @for (warning of h.warnings; track $index) { -
{{ warning }}
- } @empty { -

No hydration warnings.

+ @if (h.warningsCaptured === false) { +

Warnings

+

+ Not captured: add provideNgDevtoolsHttp() to the app providers. +

+ } @else { +

Warnings ({{ h.warnings.length }})

+ @for (warning of h.warnings; track $index) { +
{{ warning }}
+ } @empty { +

No hydration warnings.

+ } } } @else { -

No hydration data for this page.

+
+

No hydration data for this page.

+

Open a server rendered page of the app to see its stats.

+
}
-

TransferState payload

+

TransferState payload

@if (selected()?.payload; as payload) { @if (!payload.found) { -

No TransferState script: this page was not server rendered.

+
+

No TransferState script: this page was not server rendered.

+
} @else { -

+

{{ payload.entries.length }} entr{{ payload.entries.length === 1 ? 'y' : 'ies' }}, {{ payload.size }} bytes

@if (payload.error) { } - @for (entry of payload.entries; track entry.key) { -
- - @if (entry.http; as http) { - HTTP {{ http.status }} - {{ http.url ?? entry.key }} - } @else { - {{ entry.key }} - } - {{ entry.size }} B - -
{{ entry.value | json }}
-
- } +
+ @for (entry of payload.entries; track entry.key) { +
+ + + @if (payloadLabel(entry); as label) { + {{ label }} + } + @if (entry.http; as http) { + HTTP {{ http.status }} + {{ http.url ?? entry.key }} + } @else { + {{ entry.key }} + } + {{ entry.size }} B + +
{{ entry.value | json }}
+
+ } +
} } @else { -

Open a page of the app to see its payload.

+
+

Open a page of the app to see its payload.

+
}
`, styles: ` + @use 'mixins' as m; + :host { display: block; - color: #e4e4e7; + color: var(--text); font-size: 13px; } + .state { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-2); + } + .state.error { + flex-direction: column; + align-items: flex-start; + gap: 4px; + @include m.soft(var(--danger)); + } + .state.error strong { + font-weight: 600; + } + .state.error span { + color: var(--text); + } + .spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; flex-wrap: wrap; - align-items: end; - gap: 12px; - margin-bottom: 16px; + align-items: center; + gap: 8px 12px; + margin: 0 0 16px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + .toolbar .page-picker { + display: flex; + flex: 0 1 auto; + align-items: center; + gap: 8px; + min-width: 0; + } + .page-picker > span { + @include m.label; + } + .toolbar app-select { + width: 280px; + max-width: 420px; + } + .field-group { + display: grid; + gap: 6px; + min-width: 0; + color: var(--text-2); + font-size: 12px; + font-weight: 500; + } + .field-group app-select { + width: 100%; + } + .message { + flex: 1 1 200px; + min-width: 0; + margin: 0; + color: var(--text-2); + font-size: 12px; + text-align: right; + } + .message:empty { + display: none; } .grid { display: grid; gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); } .panel { - padding: 14px; - border: 1px solid #27272a; - border-radius: 10px; - background: #18181b; + @include m.panel; + @include m.enter; min-width: 0; + padding: 16px; } .wide { grid-column: 1 / -1; } - h3 { - margin: 0 0 12px; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: #d4d4d8; + h2 { + @include m.label; + margin: 0 0 16px; } - h4 { - margin: 12px 0 6px; + h3 { + margin: 16px 0 8px; + color: var(--text-strong); font-size: 12px; - color: #d4d4d8; + font-weight: 600; + } + p { + margin: 0; } label { display: grid; - gap: 4px; - color: #a1a1aa; + gap: 6px; + color: var(--text-2); font-size: 12px; + font-weight: 500; } label.inline { display: flex; align-items: center; gap: 8px; - color: #e4e4e7; + min-width: 0; + color: var(--text); + cursor: pointer; + } + .hint { + color: var(--text-3); + font-weight: 400; } input, select, textarea, button { font: inherit; - color: #e4e4e7; + color: var(--text); } input:not([type='checkbox']), select, textarea { - padding: 6px 8px; - border: 1px solid #3f3f46; - border-radius: 6px; - background: #09090b; + width: 100%; + min-width: 0; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background-color: var(--bg); + transition: + border-color 0.2s var(--ease), + box-shadow 0.2s var(--ease); + } + input:not([type='checkbox']), + select { + height: 34px; + } + select { + padding-right: 36px; + } + textarea { + padding: 8px 10px; + font-family: var(--font-mono); + font-size: 12px; + } + input::placeholder, + textarea::placeholder { + color: var(--text-3); + } + input:not([type='checkbox']):hover, + select:hover, + textarea:hover { + border-color: color-mix(in srgb, var(--border-strong) 60%, var(--text-3)); + } + input:not([type='checkbox']):focus-visible, + select:focus-visible, + textarea:focus-visible { + @include m.field-focus; + } + textarea[aria-invalid='true'] { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + } + input[type='checkbox'] { + flex: none; + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; } button { - padding: 6px 12px; - border: 1px solid #3f3f46; - border-radius: 6px; - background: #27272a; + display: inline-flex; + align-items: center; + justify-content: center; + height: 34px; + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + font-weight: 500; + white-space: nowrap; cursor: pointer; + transition: + background-color 0.2s var(--ease), + border-color 0.2s var(--ease), + color 0.2s var(--ease), + transform 0.1s var(--ease); } button:hover:not(:disabled) { - background: #3f3f46; + border-color: var(--accent-line); + background: var(--surface-3); + } + button:active:not(:disabled) { + transform: translateY(1px); + } + button[type='submit'] { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; + } + button[type='submit']:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); } button:disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } + button.ghost { + height: 28px; + padding: 0 10px; + border-color: transparent; + background: none; + color: var(--text-2); + font-size: 12px; + } + button.ghost:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--surface-2); + color: var(--text); + } button.link { + display: inline; + height: auto; padding: 0; border: none; + border-radius: 4px; background: none; - color: #93c5fd; + color: var(--text); text-align: left; - word-break: break-all; } - :focus-visible { - outline: 2px solid #93c5fd; - outline-offset: 2px; + button.link:hover:not(:disabled) { + border: none; + background: none; + color: var(--accent); } - .message { - margin: 0; - color: #a1a1aa; + button.link:active:not(:disabled) { + transform: none; + } + :focus-visible { + @include m.focus-ring; } .muted { - color: #a1a1aa; + color: var(--text-2); } .small { font-size: 12px; } .bad { - color: #fca5a5; + color: var(--danger); + } + .empty { + display: grid; + gap: 4px; + padding: 24px 16px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + text-align: center; + line-height: 1.5; } .table-wrap { overflow-x: auto; + margin: 0 -16px; + padding: 0 16px; } table { width: 100%; + min-width: 560px; border-collapse: collapse; + font-variant-numeric: tabular-nums; } th, td { - padding: 6px 8px; - border-bottom: 1px solid #27272a; + height: 36px; + padding: 6px 12px; + border-bottom: 1px solid var(--border); text-align: left; - vertical-align: top; + vertical-align: middle; + white-space: nowrap; + } + th:first-child, + td:first-child { + padding-left: 8px; } th { - color: #a1a1aa; - font-weight: 500; + @include m.label; + height: 32px; + border-bottom-color: var(--border-strong); + } + .num { + text-align: right; + } + td.num { + color: var(--text-2); + } + tbody tr { + cursor: pointer; + } + tbody td { + transition: background-color 0.15s var(--ease); + } + tbody tr:hover td { + background: var(--surface-2); + } + tr.selected td, + tr.selected:hover td { + background: var(--accent-soft); } - tr.selected td { - background: #27272a; + tr.selected td:first-child { + box-shadow: inset 2px 0 0 var(--accent); + } + .method { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-strong); } td.url { - max-width: 420px; + width: 100%; + max-width: 0; + font-family: var(--font-mono); + font-size: 12px; + } + td.url button.link { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + tr.selected td.url button.link { + color: var(--text-strong); + } + td.page-url { + max-width: 160px; + overflow: hidden; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + .rule-note { + grid-column: 1 / -1; + margin: 0; + padding-left: 24px; + color: var(--text-2); + font-size: 12px; + } + .mismatches li { + display: grid; + gap: 4px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + } + .mismatch-row { + display: flex; + gap: 8px; + min-width: 0; + font-size: 12px; + } + .mismatch-row code { + min-width: 0; + overflow-wrap: anywhere; + } + .mismatch-label { + flex: none; + width: 64px; + color: var(--text-2); + } + .status { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--text-2); + } + .status.ok { + color: var(--ok); + } + .status.bad { + color: var(--danger); + } + .dot { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 50%; + background: currentColor; + vertical-align: middle; } .notes { display: flex; - flex-wrap: wrap; gap: 4px; } .tag { - display: inline-block; - padding: 1px 6px; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px solid var(--border-strong); border-radius: 99px; - background: #3f3f46; + background: var(--surface-3); + color: var(--text-2); font-size: 11px; + font-weight: 600; white-space: nowrap; } .tag.server { - background: #1e3a8a; - color: #dbeafe; + @include m.soft(var(--accent)); } .tag.fault { - background: #7f1d1d; - color: #fee2e2; + @include m.soft(var(--danger)); + } + .tag.on { + @include m.soft(var(--ok)); + } + .tag.off-tag { + @include m.soft(var(--warn)); } pre { - margin: 4px 0 0; - padding: 8px; + margin: 0; + padding: 10px 12px; max-height: 240px; overflow: auto; - border-radius: 6px; - background: #09090b; - color: #a1a1aa; - font: - 12px ui-monospace, - monospace; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text-2); + font: 12px/1.5 var(--font-mono); white-space: pre-wrap; - word-break: break-all; + overflow-wrap: anywhere; + } + pre + pre { + margin-top: 8px; } pre.warn { - color: #fde68a; + border-color: color-mix(in srgb, var(--warn) 30%, transparent); + color: var(--warn); + } + pre.error { + border-color: color-mix(in srgb, var(--danger) 30%, transparent); + color: var(--danger); + } + .preview { + @include m.enter(0.2s); + margin-top: 16px; + } + .preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + } + .preview-head h3 { + margin: 0; + } + .preview-meta { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + margin-bottom: 8px; + } + .preview-url { + min-width: 0; + overflow: hidden; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; } .rule-form { display: grid; - gap: 8px; + gap: 12px; } .row { display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + @media (max-width: 420px) { + .row { + grid-template-columns: minmax(0, 1fr); + } + } + .field-error { + margin-top: -4px; + color: var(--danger); + font-size: 12px; + } + .field-error:empty { + display: none; + } + .form-actions { + display: flex; + } + .rules-heading { + margin-top: 24px; } .rules, .plain { display: grid; gap: 8px; - margin: 12px 0 0; + margin: 0; padding: 0; list-style: none; } .rules li { display: grid; - gap: 2px; - padding: 8px; - border: 1px solid #27272a; - border-radius: 6px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 12px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + transition: + border-color 0.2s var(--ease), + opacity 0.2s var(--ease); + } + .rules li:hover { + border-color: var(--border-strong); + } + .rules li.off .rule-pattern { + color: var(--text-2); + text-decoration: line-through; + } + .rules li.off .rule-method, + .rules li.off .rule-meta, + .rules li.off .rule-meta .status { + color: var(--text-3); } - dl { + .rules li.empty-rule { + display: block; + padding: 16px 12px; + border-style: dashed; + border-color: var(--border-strong); + background: none; + color: var(--text-2); + text-align: center; + } + .rule-pattern { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + text-overflow: ellipsis; + white-space: nowrap; + } + .rule-method { + color: var(--accent); + font-weight: 600; + } + .rule-meta { + display: flex; + flex-wrap: wrap; + grid-column: 1 / -1; + gap: 4px 8px; + padding-left: 24px; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + .rule-meta > span + span::before { + content: '·'; + margin-right: 8px; + color: var(--text-3); + } + .rule-meta .status { + font-size: 11px; + } + .rules button.remove { + padding: 2px 4px; + color: var(--text-2); + font-size: 12px; + } + .rules button.remove:hover:not(:disabled) { + color: var(--danger); + } + .plain li { + overflow-wrap: anywhere; + } + code { + font-family: var(--font-mono); + font-size: 12px; + } + .stats { display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 12px; margin: 0; } + .stats > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 36px; + padding: 6px 0; + border-bottom: 1px solid var(--border); + } + .stats > div:last-child { + border-bottom: none; + } dt { - color: #a1a1aa; + color: var(--text-2); } dd { margin: 0; + color: var(--text-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + } + .ok-note { + color: var(--text-2); + } + .summary-line { + margin-bottom: 8px; + font-variant-numeric: tabular-nums; + } + .entries { + border-top: 1px solid var(--border); } details { - border-bottom: 1px solid #27272a; - padding: 6px 0; + border-bottom: 1px solid var(--border); + } + details pre { + margin: 0 0 12px; } summary { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 6px 4px; + border-radius: 4px; + list-style: none; cursor: pointer; - word-break: break-all; + transition: background-color 0.15s var(--ease); + } + summary::-webkit-details-marker { + display: none; + } + summary:hover { + background: var(--surface-2); + } + .chevron { + flex: none; + width: 6px; + height: 6px; + margin: 0 4px; + border-right: 1.5px solid var(--text-3); + border-bottom: 1.5px solid var(--text-3); + transform: rotate(-45deg); + transition: transform 0.15s var(--ease); + } + details[open] .chevron { + transform: rotate(45deg); + } + .entry-key { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + summary:hover .entry-key { + color: var(--accent); + } + .entry-size { + flex: none; + color: var(--text-2); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + @media (max-width: 760px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } + .toolbar .page-picker { + flex: 1 1 100%; + } + .toolbar app-select { + flex: 1 1 auto; + width: auto; + max-width: none; + } + .message { + text-align: left; + } } `, }) @@ -575,27 +1271,50 @@ export class NetworkInspector { readonly serverCalls = signal([]); readonly pages = signal([]); readonly rules = signal([]); - readonly selectedPageId = signal(null); + private readonly hostPageId = hostPageId(); + readonly selectedPageId = linkedSignal({ + source: () => this.pages(), + computation: (pages, previous) => + previous?.value && pages.some((p) => p.pageId === previous.value) + ? previous.value + : (pages.find((p) => p.pageId === this.hostPageId)?.pageId ?? pages[0]?.pageId ?? null), + }); readonly selectedCallId = signal(null); readonly draft = signal({ ...EMPTY_DRAFT }); readonly message = signal(''); + readonly bodyPlaceholder = '{ "error": "Service unavailable" }'; private unsubscribe: (() => void) | null = null; private readonly destroyRef = inject(DestroyRef); - readonly selected = computed(() => { - const pages = this.pages(); - return pages.find((p) => p.pageId === this.selectedPageId()) ?? pages[0] ?? null; + readonly selected = computed( + () => this.pages().find((p) => p.pageId === this.selectedPageId()) ?? null, + ); + + readonly pageServerCalls = computed(() => { + const initialUrl = this.selected()?.initialUrl; + if (!initialUrl) return this.serverCalls(); + return this.serverCalls().filter((c) => !c.pageUrl || c.pageUrl === initialUrl); }); readonly timeline = computed(() => - [...this.serverCalls(), ...(this.selected()?.calls ?? [])].sort((a, b) => b.at - a.at), + [...this.pageServerCalls(), ...(this.selected()?.calls ?? [])].sort((a, b) => b.at - a.at), ); readonly selectedCall = computed( () => this.timeline().find((c) => c.id === this.selectedCallId()) ?? null, ); + readonly draftMocksOnServer = computed(() => { + const draft = this.draft(); + const status = Number(draft.status); + return ( + !!draft.status && + Number.isFinite(status) && + this.mocksOnServer({ target: draft.target, status }) + ); + }); + readonly bodyError = computed(() => { const body = this.draft().body.trim(); if (!body) return ''; @@ -637,11 +1356,52 @@ export class NetworkInspector { } } - selectPage(event: Event) { - this.selectedPageId.set((event.target as HTMLSelectElement).value || null); + statusTone(status: number | undefined): 'ok' | 'redirect' | 'bad' | 'neutral' { + if (status === undefined) return 'neutral'; + if (status === 0 || status >= 400) return 'bad'; + if (status >= 300) return 'redirect'; + if (status >= 200) return 'ok'; + return 'neutral'; + } + + mocksOnServer(rule: Pick): boolean { + return rule.target !== 'client' && rule.status !== undefined && rule.status < 400; + } + + payloadLabel(entry: PayloadEntry): string | null { + if (entry.source === 'hydration') return 'hydration annotations'; + if (entry.source === 'analog') return 'Analog'; + return null; + } + + targetLabel(target: HttpRule['target']): string { + return target === 'both' ? 'SSR + client' : target === 'server' ? 'SSR only' : 'Client only'; + } + + readonly pageOptions = computed(() => + this.pages().map((page) => ({ value: page.pageId, label: page.title || page.url })), + ); + + readonly methodOptions: SelectOption[] = [ + { value: '', label: 'Any' }, + ...['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => ({ value: m, label: m })), + ]; + + readonly targetOptions: SelectOption[] = [ + { value: 'both', label: 'SSR + client' }, + { value: 'server', label: 'SSR only' }, + { value: 'client', label: 'Client only' }, + ]; + + selectPage(pageId: string | null) { + if (pageId) this.selectedPageId.set(pageId); this.selectedCallId.set(null); } + setDraft(key: K, value: RuleDraft[K]) { + this.draft.update((draft) => ({ ...draft, [key]: value })); + } + patch(key: keyof RuleDraft, event: Event) { const value = (event.target as HTMLInputElement).value; this.draft.update((draft) => ({ ...draft, [key]: value })); diff --git a/app/src/pages/pipes-inspector.ts b/app/src/pages/pipes-inspector.ts index 489938e..83a2dbf 100644 --- a/app/src/pages/pipes-inspector.ts +++ b/app/src/pages/pipes-inspector.ts @@ -1,5 +1,15 @@ -import { Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { + Component, + DestroyRef, + ElementRef, + computed, + effect, + inject, + input, + signal, +} from '@angular/core'; import type { DevframeRpcClient } from 'devframe/client'; +import { Select, type SelectOption } from '../ui/select'; interface UsageSite { file: string; @@ -18,9 +28,15 @@ interface PipeInfo { usages?: UsageSite[]; } +interface PipeTarget { + pageId: string; + id: string; +} + interface PipeComponentUsage { name: string; count: number; + targets?: PipeTarget[]; } interface PipeInstanceCall { @@ -57,6 +73,7 @@ interface AsyncUsageInfo { hasSource: boolean; latestValue?: string; duplicate: boolean; + target?: PipeTarget; } interface PipeLintFinding { @@ -75,467 +92,971 @@ interface PipesSnapshot { instrumented: string[]; } +type PipeKind = 'all' | 'custom' | 'builtin' | 'impure' | 'live'; + +const KIND_OPTIONS: readonly SelectOption[] = [ + { value: 'all', label: 'All pipes' }, + { value: 'custom', label: 'Custom' }, + { value: 'builtin', label: 'Built-in' }, + { value: 'impure', label: 'Impure' }, + { value: 'live', label: 'On the page' }, +]; + @Component({ selector: 'app-pipes-inspector', + imports: [Select], template: ` +

+ Pipes declared in your source and the built-in pipes your templates use. Live data shows which + components use each pipe on the page; turn on recording to capture calls and the last input + and output. +

+
- - - + + + {{ filtered().length }} of {{ pipes().length }} +
+ + +
+ @if (instrumenting()) { -

- Recording live call counts, instance counts and last input/output. This patches pipe - prototypes in the inspected page, so turn it off when you're done. +

+ + + Recording on {{ instrumentedPages().length }} page(s). + Pipe prototypes are patched in the inspected page to count calls and keep the last input + and output. Stop recording when you are done. +

} - @if (loading()) { -

Scanning pipes…

- } @else if (filtered().length === 0) { -

No pipes found.

+ @if (loading() && pipes().length === 0) { +
+ +

Scanning pipes…

+
+ } @else if (loadFailed() && pipes().length === 0) { + + } @else if (pipes().length === 0) { +
+

No pipes found

+

+ No @Pipe classes were found in the source, and no template uses a built-in pipe yet. +

+
} @else { -
    - @for (p of filtered(); track p.file + p.name) { -
  • - +
  • + } +
+ } @else { +
+

No pipes match

+

+ @if (filter().trim()) { + Nothing matches “{{ filter().trim() }}” in this view. + } @else { + None of the pipes fit this filter. } - @if (p.builtin) { - built-in +

+ +
+ } +
+ +
+ @if (selected(); as p) { +
+

{{ p.name }}

+ {{ + p.isPure ? 'pure' : 'impure' + }} + @if (p.builtin) { + built-in + } +
+ +
+

Declaration

+
+
Class
+
{{ p.className }}
+
Source
+
{{ p.builtin ? '@angular/common' : 'This project' }}
+ @if (!p.builtin) { +
File
+
{{ p.file }}:{{ p.line }}
} +
Standalone
+
{{ p.isStandalone ? 'Yes' : 'No, declared in an NgModule' }}
+
Pure
+
+ {{ + p.isPure + ? 'Yes, reruns only when an input changes' + : 'No, reruns on every check' + }} +
+
+
+ + @if (p.builtin && p.usages?.length) { +
+

+ Used in templates {{ p.usageCount }} +

+
    + @for (u of p.usages; track u.file + ':' + u.line) { +
  • {{ u.file }}:{{ u.line }}
  • + } +
-
- {{ p.file }}:{{ p.line }} - @if (p.builtin && (p.usageCount ?? 0) > 1) { - +{{ (p.usageCount ?? 1) - 1 }} more + } + +
+

Live on the page

+ @if (liveFor(p.name); as live) { + @if (live.stale) { +

+ experimental + This pure pipe got an argument whose contents changed while its reference stayed + the same, so it may be showing a stale value. +

} -
- - @if (isSelected(p)) { -
-
Class
-
{{ p.className }}
-
Source
-
{{ p.builtin ? '@angular/common' : 'This project' }}
- @if (!p.builtin) { -
File
-
{{ p.file }}:{{ p.line }}
- } -
Standalone
-
{{ p.isStandalone ? 'Yes' : 'No' }}
-
Pure
-
{{ p.isPure ? 'Yes' : 'No' }}
- @if (p.builtin && p.usages?.length) { -
Used in ({{ p.usageCount }})
-
-
    - @for (u of p.usages; track u.file + ':' + u.line) { -
  • {{ u.file }}:{{ u.line }}
  • - } -
-
- } -
- @if (liveFor(p.name); as live) { -
-
Live
- @if (live.stale) { -

- experimental - Fed an argument that changed contents without changing reference — this pure - pipe may be showing a stale value. -

- } -
-
Instances
-
{{ live.instanceCount }}
-
Used by
+
Instances
+
{{ live.instanceCount }}
+
Used by
+
+
    + @for (c of live.components; track c.name) { +
  • + @if (c.targets?.[0]; as target) { + + } @else { + + {{ c.name }} + {{ c.count }} + + } +
  • + } +
+
+ @if (live.call; as call) { +
Calls
+
{{ call.callCount }}
+
Last input
+
{{ describe(call.lastArgs) }}
+
Last output
+
{{ describe(call.lastResult) }}
+ @if (call.instances?.length) { +
Per instance
-
    - @for (c of live.components; track c.name) { -
  • {{ c.name }} ({{ c.count }})
  • +
      + @for (i of call.instances; track $index) { +
    • + {{ describe(i.lastArgs) }} → {{ describe(i.lastResult) }} + {{ i.callCount }}× +
    • }
- @if (live.call; as call) { -
Calls
-
{{ call.callCount }}
-
Last input
-
{{ describe(call.lastArgs) }}
-
Last output
-
{{ describe(call.lastResult) }}
- @if (call.instances?.length) { -
Per instance
-
-
    - @for (i of call.instances; track $index) { -
  • - {{ describe(i.lastArgs) }} → {{ describe(i.lastResult) }} ({{ - i.callCount - }}) -
  • - } -
-
- } - @if (call.lastCaller) { -
Last caller
-
{{ call.lastCaller }}
- } - } @else if (instrumenting()) { -
Calls
-
None recorded yet.
- } -
-
- } @else if (instrumenting()) { -

Not seen on the page yet.

+ } + @if (call.lastCaller) { +
Last caller
+
{{ call.lastCaller }}
+ } + } @else if (instrumenting()) { +
Calls
+
None recorded yet.
+ } + + @if (!live.call && !instrumenting()) { +

Turn on “Record calls” to see call counts and values.

} -
- } - - } - + } @else { +

+ Not in use on the connected page. Open a view that uses it, or connect the app. +

+ } +
+ } @else { +

Pipe details

+
+

Pick a pipe

+

+ See where it is declared, which components use it and what it last returned. +

+
+ } +
+ } @if (async().length > 0) { -
-

Async pipes ({{ async().length }})

-
    +
    +

    + Async subscriptions {{ async().length }} +

    +

    + Each | async subscribes on its own. Two on the same source mean + the work runs twice. +

    +
      @for (a of async(); track $index) { -
    • -
      - {{ a.component }} - @if (!a.hasSource) { - no source - } - @if (a.duplicate) { - duplicate subscription - } -
      -
      {{ a.latestValue ?? '(none yet)' }}
      +
    • + @if (a.target; as target) { + + } @else { + {{ a.component }} + } + @if (!a.hasSource) { + no source + } + @if (a.duplicate) { + duplicate subscription + } + {{ a.latestValue ?? 'no value yet' }}
    • }
    -
+ } -
-

Lint

+
+

+ Lint + @if (lint(); as findings) { + {{ findings.length }} + } +

@if (lintFailed()) { - + } @else if (lint() === null) { -

Checking…

+

Checking…

} @else if (!lint()!.length) { -

No problems found.

+

No problems found.

} @else { -
    +
      @for (f of lint(); track $index) {
    • - {{ f.severity }} - {{ f.rule }} - on {{ f.pipe }} at {{ f.file }}:{{ f.line }} -
      {{ f.message }}
      -
      Fix: {{ f.fix }}
      +
      + {{ f.severity }} + {{ f.rule }} + {{ f.pipe }} · {{ f.file }}:{{ f.line }} +
      +

      {{ f.message }}

      +

      Fix {{ f.fix }}

    • }
    } -
+ `, styles: ` + @use 'mixins' as m; + + :host { + display: block; + color: var(--text); + font-size: 13px; + } + .mono { + font-family: var(--font-mono); + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + .intro { + max-width: 720px; + margin: 0 0 12px; + color: var(--text-2); + line-height: 1.5; + } .toolbar { + position: sticky; + top: 0; + z-index: 2; display: flex; - gap: 8px; - margin-bottom: 16px; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + margin: 0 0 12px; + padding: 8px; + background: color-mix(in srgb, var(--surface) 85%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: var(--radius); } - input { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + .search { + position: relative; + flex: 1 1 220px; + min-width: 0; } - input:focus { - border-color: var(--accent); + .search-icon { + position: absolute; + top: 50%; + left: 12px; + width: 14px; + height: 14px; + transform: translateY(-50%); + fill: none; + stroke: var(--text-3); + stroke-width: 2.2; + stroke-linecap: round; + pointer-events: none; } - button { - padding: 8px 16px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; - cursor: pointer; + .search:focus-within .search-icon { + stroke: var(--accent); + } + input[type='search'] { + width: 100%; + height: var(--control-h); + padding: 0 12px 0 34px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); font-size: 13px; + transition: + border-color 150ms var(--ease), + box-shadow 150ms var(--ease); } - button:hover { - background: #52525b; + input[type='search']::placeholder { + color: var(--text-3); } - button.instrument.on { - background: #7c2d12; - color: #fdba74; + input[type='search']:focus-visible { + @include m.field-focus; } - .muted { - color: #71717a; - font-size: 14px; + .toolbar app-select { + width: 150px; } - .instrument-hint { - margin: -8px 0 16px; + .total { + flex: none; + color: var(--text-2); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; } - .live-section { - padding: 12px 16px 4px; - border-top: 1px solid #27272a; + .actions { + display: flex; + gap: 8px; + margin-left: auto; } - .live-heading { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #71717a; - margin-bottom: 8px; + button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: var(--control-h); + padding: 0 14px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); } - .live-hint { - padding: 0 16px 12px; - margin: 0; + button:hover:not(:disabled) { + border-color: var(--accent-line); + background: var(--surface-3); + } + button:focus-visible { + @include m.focus-ring; + } + button:disabled { + cursor: default; + opacity: 0.6; + } + .rec-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-3); } - .stale-warning { + .record.on { + @include m.soft(var(--accent)); + } + .record.on .rec-dot { + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + } + .notice { display: flex; - align-items: baseline; + align-items: flex-start; gap: 8px; - font-size: 12px; - color: #fdba74; - background: #431407; - border-radius: 6px; - padding: 8px 10px; margin: 0 0 12px; + padding: 10px 12px; + border: 1px solid var(--accent-line); + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--text-2); + line-height: 1.5; + @include m.enter(0.2s); } - .mono { - font-family: monospace; - font-size: 12px; - word-break: break-all; + .notice strong { + color: var(--text-strong); + font-weight: 600; } - .pipe-list { - list-style: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; + .notice svg { + flex: none; + width: 16px; + height: 16px; + margin-top: 2px; + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linecap: round; } - .pipe-item { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 0; - transition: border-color 0.15s; + .layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + align-items: start; } - .pipe-item:has(.pipe-toggle:hover) { - border-color: var(--accent); + @media (min-width: 880px) { + .layout { + grid-template-columns: minmax(0, 1fr) minmax(340px, 44%); + } + .detail { + position: sticky; + top: 64px; + max-height: calc(100vh - 150px); + overflow: auto; + } } - .pipe-item.expanded { - border-color: var(--accent); + .list-wrap { + min-width: 0; } - .pipe-toggle { - display: block; + .pipe-list { + display: grid; + gap: 2px; + margin: 0; + padding: 6px; + list-style: none; + @include m.panel; + @include m.enter; + } + .row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 12px; width: 100%; - padding: 12px 16px; + height: auto; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-sm); background: none; - border: none; - color: inherit; text-align: left; - cursor: pointer; - font: inherit; + font-weight: 400; + white-space: normal; + } + .row:hover:not(:disabled) { + border-color: transparent; + background: var(--surface-2); + } + .row:focus-visible { + @include m.focus-ring(-2px); } - .name-row { + .row.selected { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); + } + .row-main { display: flex; - align-items: center; + align-items: baseline; gap: 8px; + min-width: 0; } .name { - font-family: monospace; - font-size: 15px; - color: var(--accent); + flex: none; + color: var(--text-strong); + font-size: 13px; font-weight: 600; } - .badge { + .sub { + color: var(--text-3); + font-size: 12px; + @include m.truncate; + } + .row-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + } + .row-file { + grid-column: 1 / -1; + color: var(--text-3); font-size: 11px; - padding: 2px 8px; - border-radius: 4px; - background: #14532d; - color: #4ade80; + overflow-wrap: anywhere; + } + .more { + margin-left: 6px; + color: var(--text-2); + } + .chip { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 7px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text-2); + font-size: 10px; font-weight: 600; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.05em; + white-space: nowrap; } - .badge.impure { - background: #7c2d12; - color: #fdba74; + .chip.impure, + .chip.warn { + @include m.soft(var(--warn)); } - .badge.module { - background: #3f3f46; - color: #a1a1aa; + .chip.live { + @include m.soft(var(--ok)); } - .badge.builtin { - background: #1e3a8a; - color: #93c5fd; + .pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + font-weight: 600; + letter-spacing: 0; + font-variant-numeric: tabular-nums; } - .file { - font-size: 12px; - color: #71717a; - margin-top: 2px; + .detail { + min-width: 0; + @include m.panel; + @include m.enter; } - .usage-count { - margin-left: 6px; - color: #52525b; + .detail-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); } - .usage-list { - list-style: none; + .detail-head h2 { + flex: 1 1 auto; + min-width: 0; margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 2px; + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + overflow-wrap: anywhere; } - .inline-detail { - padding: 0 16px 12px; - border-top: 1px solid #27272a; - margin-top: 0; - padding-top: 12px; + .block { + padding: 14px 16px; + border-bottom: 1px solid var(--border); + } + .block:last-child { + border-bottom: 0; + } + h3 { + @include m.label; + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 10px; } dl { display: grid; - grid-template-columns: auto 1fr; - gap: 4px 12px; - font-size: 13px; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px 14px; + margin: 0; } dt { - color: #71717a; + color: var(--text-3); } dd { - color: #e4e4e7; + min-width: 0; + margin: 0; + color: var(--text); } - .async-panel { - margin-top: 20px; + .value { + font-size: 12px; + overflow-wrap: anywhere; } - .async-heading { - font-size: 13px; - font-weight: 600; - color: #a1a1aa; - margin: 0 0 8px; + .muted { + color: var(--text-2); } - .async-list { + .sites { + display: grid; + gap: 4px; + margin: 0; + padding: 0; list-style: none; + color: var(--text-2); + font-size: 12px; + overflow-wrap: anywhere; + } + .calls { + margin-left: 6px; + color: var(--text-3); + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; padding: 0; + list-style: none; + } + .component { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 4px 0 10px; + border: 1px solid var(--border-strong); + border-radius: 99px; + background: var(--bg); + font-size: 12px; + transition: + border-color 150ms var(--ease), + background-color 150ms var(--ease); + } + button.component { + height: 24px; + padding: 0 4px 0 10px; + border-radius: 99px; + background: var(--bg); + font-size: 12px; + font-weight: 400; + } + .count { + padding: 0 6px; + border-radius: 99px; + background: var(--surface-3); + color: var(--text-2); + font-size: 10px; + line-height: 16px; + font-variant-numeric: tabular-nums; + } + .warning { display: flex; - flex-direction: column; + flex-wrap: wrap; + align-items: baseline; gap: 8px; + margin: 0 0 12px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warn) 8%, transparent); + color: var(--text); + line-height: 1.5; + } + .hint { + margin: 8px 0 0; + color: var(--text-3); + font-size: 12px; + } + .empty-line { + margin: 0; + color: var(--text-2); + line-height: 1.5; } - .async-item { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 10px 16px; + .empty-line.ok { + color: var(--ok); } - .async-row { + .section { + margin-top: 20px; + @include m.enter; + } + .section h2 { + @include m.label; display: flex; align-items: center; gap: 8px; + margin: 0 0 8px; } - .component { - font-family: monospace; + .section > .hint { + margin: -2px 0 10px; + } + .async-list, + .findings { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + .async-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + min-width: 0; + padding: 8px 12px; + border-radius: var(--radius-sm); + @include m.panel; + transition: + background-color 150ms var(--ease), + border-color 150ms var(--ease); + } + .async-row:hover { + border-color: var(--border-strong); + background: var(--surface-2); + } + .component-name { + color: var(--text-strong); + font-weight: 600; + } + button.component-name { + height: 24px; + padding: 0 8px; + margin-left: -8px; + border-color: transparent; + background: none; font-size: 13px; + } + .latest { + flex: 1 1 100%; + color: var(--text-2); + } + .finding { + padding: 10px 12px; + border-radius: var(--radius-sm); + @include m.panel; + } + .finding-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + } + .severity { + padding: 0 7px; + border: 1px solid var(--border-strong); + border-radius: 99px; + color: var(--text-2); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 16px; + text-transform: uppercase; + } + .severity[data-tone='warning'] { + @include m.soft(var(--warn)); + } + .severity[data-tone='error'] { + @include m.soft(var(--danger)); + } + .rule { color: var(--accent); + font-size: 12px; } - .async-value { - margin-top: 4px; - color: #a1a1aa; + .where { + color: var(--text-3); + font-size: 11px; + overflow-wrap: anywhere; } - .lint-panel { - margin-top: 20px; + .finding-message { + margin: 6px 0 0; + color: var(--text); + line-height: 1.5; } - .findings { - list-style: none; - padding: 0; + .finding-fix { + margin: 4px 0 0; + color: var(--text-2); + line-height: 1.5; + } + .fix-label { + @include m.label; + margin-right: 4px; + } + .state { display: flex; flex-direction: column; + align-items: center; gap: 8px; - font-size: 13px; + padding: 40px 16px; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + @include m.enter; } - .finding { - background: #18181b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 10px 16px; + .state.compact { + padding: 28px 16px; + border: 0; + background: none; } - .finding-rule { - color: #c4b5fd; + .list-wrap .state.compact { + @include m.panel; } - .finding-meta, - .finding-message, - .finding-fix { - display: block; - margin-top: 0.5rem; + .state-title { + margin: 0; + color: var(--text-strong); + font-size: 14px; + font-weight: 600; } - .tag { - display: inline-block; - margin: 0 4px 2px 0; - padding: 0 5px; - border: 1px solid #3f3f46; - border-radius: 4px; - color: #d4d4d8; - font-size: 11px; + .state-hint { + max-width: 440px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } + .state button { + margin-top: 8px; } - .tag[data-tone='warn'] { - border-color: #a16207; - color: #fef08a; + .spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; } - .tag[data-tone='bad'] { - border-color: #b91c1c; - color: #fca5a5; + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } } `, }) export class PipesInspector { private readonly destroyRef = inject(DestroyRef); + private readonly host = inject>(ElementRef); rpc = input(null); + protected readonly kindOptions = KIND_OPTIONS; + pipes = signal([]); filter = signal(''); + kind = signal('all'); loading = signal(false); + loadFailed = signal(false); selected = signal(null); - filtered = signal([]); - live = signal([]); async = signal([]); lint = signal(null); @@ -543,24 +1064,29 @@ export class PipesInspector { instrumentedPages = signal([]); instrumenting = signal(false); - private unsubscribe?: () => void; + private readonly liveByName = computed(() => new Map(this.live().map((p) => [p.name, p]))); - constructor() { - effect(() => { - const q = this.filter().toLowerCase(); - const all = this.pipes(); - this.filtered.set( - q - ? all.filter( - (p) => - p.name.toLowerCase().includes(q) || - p.className.toLowerCase().includes(q) || - p.file.toLowerCase().includes(q), - ) - : all, + filtered = computed(() => { + const q = this.filter().trim().toLowerCase(); + const kind = this.kind() ?? 'all'; + const live = this.liveByName(); + return this.pipes().filter((p) => { + if (kind === 'custom' && p.builtin) return false; + if (kind === 'builtin' && !p.builtin) return false; + if (kind === 'impure' && p.isPure) return false; + if (kind === 'live' && !live.has(p.name)) return false; + return ( + !q || + p.name.toLowerCase().includes(q) || + p.className.toLowerCase().includes(q) || + p.file.toLowerCase().includes(q) ); }); + }); + private unsubscribe?: () => void; + + constructor() { effect(() => { const client = this.rpc(); if (client) { @@ -569,13 +1095,17 @@ export class PipesInspector { } }); - this.destroyRef.onDestroy(() => this.unsubscribe?.()); + this.destroyRef.onDestroy(() => { + this.unsubscribe?.(); + this.highlight(null); + }); } async refresh() { const client = this.rpc(); if (!client) return; this.loading.set(true); + this.loadFailed.set(false); try { const my = client.scope('ng-devtools'); const pipes = (await my.rpc.call('get-pipes')) as PipeInfo[]; @@ -586,7 +1116,7 @@ export class PipesInspector { this.selected.set(refreshed ?? null); } } catch { - // RPC not available + this.loadFailed.set(true); } finally { this.loading.set(false); } @@ -638,7 +1168,7 @@ export class PipesInspector { } liveFor(name: string): LivePipeInfo | undefined { - return this.live().find((p) => p.name === name); + return this.liveByName().get(name); } describe(value: unknown): string { @@ -660,4 +1190,39 @@ export class PipesInspector { select(pipe: PipeInfo) { this.selected.set(this.isSelected(pipe) ? null : pipe); } + + clearFilters() { + this.filter.set(''); + this.kind.set('all'); + } + + highlightPipe(live: LivePipeInfo | undefined) { + this.highlight(live?.components.find((c) => c.targets?.length)?.targets?.[0]); + } + + highlight(target: PipeTarget | null | undefined) { + const client = this.rpc(); + if (!client) return; + void client + .scope('ng-devtools') + .rpc.call('request-page-highlight', target ?? null) + .catch(() => {}); + } + + onListKey(event: KeyboardEvent) { + const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End']; + if (!keys.includes(event.key)) return; + const rows = Array.from( + this.host.nativeElement.querySelectorAll('[data-pipe-row]'), + ); + if (!rows.length) return; + const at = rows.indexOf(document.activeElement as HTMLButtonElement); + let next = at; + if (event.key === 'ArrowDown') next = Math.min(at + 1, rows.length - 1); + else if (event.key === 'ArrowUp') next = Math.max(at - 1, 0); + else if (event.key === 'Home') next = 0; + else next = rows.length - 1; + event.preventDefault(); + rows[next]?.focus(); + } } diff --git a/app/src/pages/route-current.ts b/app/src/pages/route-current.ts index 9d2ac89..e17cbdb 100644 --- a/app/src/pages/route-current.ts +++ b/app/src/pages/route-current.ts @@ -25,6 +25,7 @@ interface OutletRow { template: ` @if (page().snapshot; as snapshot) {

+ URL {{ snapshot.url }}

@if (snapshot.urlDrift && snapshot.browserUrl) { @@ -36,7 +37,10 @@ interface OutletRow { } @if (snapshot.pending; as pending) {
- Navigating to {{ pending.url }} (#{{ pending.id }}) + + Navigating to {{ pending.url }} (#{{ pending.id }})
} @@ -77,7 +81,7 @@ interface OutletRow {
+ {{ row.depth === 0 && !row.route.path ? '(root)' : '/' + row.route.path }} @if (row.route.outlet !== 'primary') { {{ row.route.outlet }} @@ -92,29 +96,38 @@ interface OutletRow { } {{ row.route.component ?? '—' }} + @if (row.route.component) { + {{ row.route.component }} + } @else { + none + } + @for (entry of entries(row.route.params); track entry[0]) { -
+
{{ entry[0] }}: {{ entry[1] | json }} @if (row.route.paramSources?.[entry[0]] === 'inherited') { inherited }
} @empty { - — + none }
@for (entry of entries(row.route.data); track entry[0]) { -
+
{{ entry[0] }}: {{ entry[1] | json }} @if (row.route.dataSources?.[entry[0]]; as source) { {{ source }} }
} @empty { - — + none }
@@ -124,8 +137,9 @@ interface OutletRow { @for (resolver of row.route.resolvers ?? []; track resolver) { resolve {{ resolver }} } - @if (!guardList(row.route).length && !row.route.resolvers) { - — + @if (!guardList(row.route).length && !row.route.resolvers?.length) { + none }
- - - - - - - - - - - @for (route of filtered(); track $index) { - - - - - - - + @if (error()) { + + } @else if (loading() && routes().length === 0) { +

Scanning routes…

+ } @else if (filtered().length === 0) { + @if (routes().length) { +
+

No routes match the filter.

+

Search by path, component, guard, title or file name.

+ +
+ } @else { +
+

No routes found.

+

+ Routes are read from *.routes.ts and *routing.module.ts + files, the files they lazy load, plus Analog pages. Add one and refresh. +

+
} - -
PathComponent / TargetTitleFileChildren
/{{ route.path }} - @if (route.redirectTo !== undefined) { - ➜ {{ route.redirectTo }} - } @else { - {{ route.component ?? '—' }} - } - {{ route.title ?? '—' }}{{ route.file }}{{ route.hasChildren ? 'Yes' : '—' }}
- } + } @else { +

+ {{ filtered().length }} of {{ routes().length }} route entries, + {{ navigable() }} navigable +

+
+ + + + + + + + + + + + @for (route of filtered(); track $index) { + + + + + + + + } + +
PathComponent / TargetGuards and resolversTitleDeclared in
+ {{ route.fullPath }} + @if (route.kind !== 'page') { + {{ route.kind }} + } + + @if (route.redirectTo !== undefined) { + redirects to + {{ route.redirectTo }} + } @else if (route.component) { + {{ route.component }} + } @else { + none + } + + @for (guard of checks(route); track $index) { + {{ guard }} + } @empty { + none + } + + @if (route.title) { + {{ route.title }} + } @else { + none + } + {{ location(route) }}
+
+ } +
+ `, styles: ` - .config-heading { - margin: 0 0 8px; - font-size: 15px; - color: #e4e4e7; + ${SHARED_STYLES} + :host { + display: block; + min-width: 0; } - .toolbar { - display: flex; - gap: 8px; - margin-bottom: 16px; - } - input { - flex: 1; - padding: 8px 12px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 6px; - color: #e4e4e7; - font-size: 14px; - outline: none; + .config { + display: grid; + gap: 12px; + min-width: 0; } - input:focus { - border-color: var(--accent); + .section-head { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + align-items: baseline; } - button { - padding: 8px 16px; - background: #3f3f46; - border: none; - border-radius: 6px; - color: #e4e4e7; - cursor: pointer; - font-size: 13px; + h2 { + margin: 0; + color: var(--text-3); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } - button:hover { - background: #52525b; + .hint, + .count { + margin: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; } - .muted { - color: #a1a1aa; - font-size: 14px; + .count { + margin-bottom: -4px; + padding: 0 4px; } - table { - width: 100%; - border-collapse: collapse; - font-size: 14px; + .config-body { + display: grid; + gap: 12px; + min-width: 0; } - thead { - position: sticky; - top: 0; + .config-body[hidden] { + display: none; } - th { - text-align: left; - padding: 8px 12px; - background: #18181b; - color: #a1a1aa; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid #27272a; + .toggle { + margin-left: auto; } - td { - padding: 10px 12px; - border-bottom: 1px solid #1e1e22; + .toolbar { + display: flex; + gap: 8px; + align-items: center; } - tr:hover td { - background: #18181b; + .toolbar .field { + flex: 1; + min-width: 0; } - .path { - font-family: monospace; - color: var(--accent); - font-weight: 500; + .component { + color: var(--text-strong); } .redirect { - font-family: monospace; - color: #38bdf8; + color: var(--text-2); + font-family: var(--font-mono); + font-size: 12.5px; + overflow-wrap: anywhere; } .file { + min-width: 160px; + color: var(--text-2); + font-family: var(--font-mono); font-size: 12px; - color: #a1a1aa; + overflow-wrap: anywhere; } `, }) export class RouteInspector { rpc = input(null); - routes = signal([]); + routes = signal([]); filter = signal(''); loading = signal(false); + error = signal(false); + + private readonly live = viewChild(LiveRoute); + readonly liveHasConfig = computed(() => !!this.live()?.page()?.config); + readonly expanded = linkedSignal(() => !this.liveHasConfig()); + + readonly navigable = computed(() => this.filtered().filter((r) => r.kind === 'page').length); filtered = computed(() => { const q = this.filter().toLowerCase().trim(); @@ -158,10 +229,11 @@ export class RouteInspector { if (!q) return all; return all.filter( (r) => - r.path.toLowerCase().includes(q) || + r.fullPath.toLowerCase().includes(q) || (r.component && r.component.toLowerCase().includes(q)) || (r.redirectTo && r.redirectTo.toLowerCase().includes(q)) || (r.title && r.title.toLowerCase().includes(q)) || + this.checks(r).some((check) => check.toLowerCase().includes(q)) || r.file.toLowerCase().includes(q), ); }); @@ -173,6 +245,19 @@ export class RouteInspector { }); } + checks(route: SourceRoute) { + return [ + ...Object.entries(route.guards ?? {}).flatMap(([kind, names]) => + names.map((name) => `${kind} ${name}`), + ), + ...(route.resolvers ?? []).map((resolver) => `resolve ${resolver}`), + ]; + } + + location(route: SourceRoute) { + return sourceLocation(route); + } + onFilterInput(event: Event) { const target = event.target as HTMLInputElement | null; this.filter.set(target?.value ?? ''); @@ -182,10 +267,13 @@ export class RouteInspector { const client = this.rpc(); if (!client) return; this.loading.set(true); + this.error.set(false); try { const my = client.scope('ng-devtools'); - const result = (await my.rpc.call('get-routes')) as RouteInfo[]; + const result = (await my.rpc.call('get-routes')) as SourceRoute[]; this.routes.set(result); + } catch { + this.error.set(true); } finally { this.loading.set(false); } diff --git a/app/src/pages/route-lint.ts b/app/src/pages/route-lint.ts index 2ebad33..96e657f 100644 --- a/app/src/pages/route-lint.ts +++ b/app/src/pages/route-lint.ts @@ -6,94 +6,188 @@ import { SHARED_STYLES, routerCall, type LintFinding, type RouterPage } from './ selector: 'app-route-lint', template: `
- + Checks the live config, links and recent navigations. Lazy routes that have not loaded are skipped. + @if (!loading() && findings().length) { + + @if (counts().error) { + {{ counts().error }} error(s) + } + @if (counts().warning) { + {{ counts().warning }} warning(s) + } + @if (counts().info) { + {{ counts().info }} info + } + + }
@if (loading()) { -

Checking…

+

Checking…

} @else if (findings().length) {
    @for (finding of findings(); track $index) { -
  • +
  • - {{ finding.severity }} - {{ finding.rule }} + {{ + finding.severity + }} + {{ finding.rule }} {{ finding.route }}
    -

    {{ finding.message }}

    +

    {{ finding.message }}

    - Fix: {{ finding.fix }} - (Angular - {{ - finding.angular === 'throws' - ? 'throws' - : finding.angular === 'warns' - ? 'warns' - : 'does not warn' - }})Fix + {{ finding.fix }} + (Angular + {{ + finding.angular === 'throws' + ? 'throws' + : finding.angular === 'warns' + ? 'warns' + : 'does not warn' + }})

  • }
} @else { -

No route config problems found.

+
+ +

No route config problems found.

+

The check runs again after each navigation or config change.

+
} `, styles: ` ${SHARED_STYLES} :host { display: grid; - gap: 10px; + gap: 12px; + min-width: 0; } .toolbar { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 8px 12px; align-items: center; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + } + .toolbar .muted { + flex: 1 1 240px; + font-size: 12px; + } + .summary { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + font-variant-numeric: tabular-nums; + } + .ok-mark { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin-bottom: 4px; + border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); + border-radius: 99px; + background: color-mix(in srgb, var(--ok) 12%, transparent); + color: var(--ok); } .findings { - list-style: none; - margin: 0; - padding: 0; display: grid; gap: 8px; + margin: 0; + padding: 0; + list-style: none; } .findings li { - padding: 10px; - border: 1px solid #27272a; - border-radius: 6px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); font-size: 13px; - color: #e4e4e7; + line-height: 1.5; + animation: enter 0.35s var(--ease) both; + transition: border-color 0.15s var(--ease); + } + .findings li:hover { + border-color: var(--border-strong); + } + .findings li[data-severity='error'] { + box-shadow: inset 3px 0 0 var(--danger); + } + .findings li[data-severity='warning'] { + box-shadow: inset 3px 0 0 var(--warn); } .head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; + min-width: 0; + } + .rule { + color: var(--text-strong); + font-weight: 500; } .route { color: var(--accent); } - p { - margin: 6px 0 0; + .message { + margin: 8px 0 0; } .fix { - color: #d4d4d8; + display: flex; + gap: 10px; + align-items: baseline; + margin: 10px 0 0; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text); + } + .fix-label { + flex: none; + color: var(--accent); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .fix-text { + min-width: 0; + overflow-wrap: anywhere; + } + .fix .muted { + font-size: 12px; } `, }) @@ -103,6 +197,11 @@ export class RouteLint { readonly findings = signal([]); readonly loading = signal(false); + readonly counts = computed(() => { + const counts = { error: 0, warning: 0, info: 0 }; + for (const finding of this.findings()) counts[finding.severity]++; + return counts; + }); private readonly key = computed( () => `${this.page().pageId}:${this.page().generation}:${this.page().navigations.length}`, ); @@ -114,6 +213,10 @@ export class RouteLint { }); } + severityTone(severity: LintFinding['severity']) { + return severity === 'error' ? 'bad' : severity === 'warning' ? 'warn' : ''; + } + async run() { this.loading.set(true); this.findings.set( diff --git a/app/src/pages/route-setup.ts b/app/src/pages/route-setup.ts index 6b6e31d..bf0c3b5 100644 --- a/app/src/pages/route-setup.ts +++ b/app/src/pages/route-setup.ts @@ -45,13 +45,13 @@ import { SHARED_STYLES, type RouterPage } from './router-types'; @for (option of setup.options; track option.name) { - + {{ option.name }} {{ option.value }} - + {{ option.set ? 'set' : 'default' }} @@ -61,56 +61,51 @@ import { SHARED_STYLES, type RouterPage } from './router-types';
-

Features

-
    - @for (feature of entries(setup.features); track feature[0]) { -
  • - {{ feature[0] }}: {{ feature[1] }} -
  • - } -
-

Strategies

-
- @for (strategy of entries(setup.strategies); track strategy[0]) { -
{{ strategy[0] }}
-
- {{ strategy[1] }} -
- } -
+ @if (entries(setup.features).length) { +

Features

+
    + @for (feature of entries(setup.features); track feature[0]) { +
  • + {{ feature[0] }}: {{ feature[1] }} +
  • + } +
+ } + @if (entries(setup.strategies).length) { +

Strategies

+
+ @for (strategy of entries(setup.strategies); track strategy[0]) { +
{{ strategy[0] }}
+
+ {{ strategy[1] }} +
+ } +
+ } } @else { -

The page has not reported its router setup yet.

+
+

The page has not reported its router setup yet.

+

+ It appears after the app finishes bootstrapping. Reload the app if it stays empty. +

+
} `, styles: ` ${SHARED_STYLES} :host { display: grid; - gap: 12px; - } - .note { - margin: 0; - padding: 8px 10px; - border-left: 3px solid #fef08a; - background: #27272a; - color: #e4e4e7; - font-size: 13px; - } - .facts { - display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 12px; - margin: 0; - font-size: 13px; + gap: 16px; + min-width: 0; } - dt { - color: #a1a1aa; + .name code { + color: var(--text-strong); } - dd { - margin: 0; - color: #e4e4e7; + .source { + width: 1%; + white-space: nowrap; } .chips { display: flex; @@ -120,6 +115,13 @@ import { SHARED_STYLES, type RouterPage } from './router-types'; padding: 0; list-style: none; } + .chips .badge { + font-family: var(--font-mono); + font-weight: 500; + } + .facts code { + color: var(--text-strong); + } `, }) export class RouteSetup { diff --git a/app/src/pages/route-timeline.ts b/app/src/pages/route-timeline.ts index 4f07684..4930a05 100644 --- a/app/src/pages/route-timeline.ts +++ b/app/src/pages/route-timeline.ts @@ -13,7 +13,7 @@ const PHASES = ['recognize', 'guards', 'resolve', 'activate'] as const; const PHASE_COLORS: Record = { recognize: '#60a5fa', guards: '#f59e0b', - resolve: '#a78bfa', + resolve: '#2dd4bf', activate: '#34d399', }; @@ -21,15 +21,8 @@ const PHASE_COLORS: Record = { selector: 'app-route-timeline', template: `
- = { /> Only problems - + +
@if (message()) { -

{{ message() }}

+

{{ message() }}

} - @if (items().length) { +
+ {{ items().length }} of {{ page().navigations.length }} navigation(s) + +
@if (bars(nav).length) {