diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md index a9d42deb..02535d98 100644 --- a/.claude/skills/devtools-docs/SKILL.md +++ b/.claude/skills/devtools-docs/SKILL.md @@ -109,6 +109,7 @@ Rules: - Inside components use HTML (``, ``, ``); markdown doesn't render there. Write `@` as `@`. - Raw HTML external links need `target="_blank" rel="noopener noreferrer"` or the build fails. Markdown links get it automatically. - `*Angular`, `*Analog`, `*Devframe`, `*NgRx`, `*MCP`, `*Vite` are keyword links (`keywords` in `ngmd.config.ts`). The asterisk is intentional. Never "fix" it. +- Panel screenshots live in `apps/docs/public/images/panel` and come from `node scripts/docs-screenshots.mjs` (`--only ` for some, `--list` for the names). Regenerate the ones a panel change affects, and give each `ngmd-image` an `alt` that says what it shows. ## 6. Code samples diff --git a/README.md b/README.md index 0a88144b..dafb8b7e 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,13 @@ npm install -D @pangular-inspector/devtools An Angular SSR server imports the hub when it starts, so install the package as a dependency there (`npm install @pangular-inspector/devtools`). -Then follow the [installation guide](./apps/docs/src/content/getting-started/installation.md) for your setup: Angular CLI with Express, Vite and Analog, the standalone CLI, [Angular Native](./apps/docs/src/content/guides/angular-native.md), or [Capacitor](./apps/docs/src/content/guides/capacitor.md). For a coding agent, run `npx @pangular-inspector/devtools mcp`. +For an Angular CLI app with SSR, the [quick start](./apps/docs/src/content/getting-started/quick-start.md) takes you from install to a live panel. For other setups, follow the [installation guide](./apps/docs/src/content/getting-started/installation.md): a client-only Angular CLI app, Vite and Analog, the standalone CLI, [Angular Native](./apps/docs/src/content/guides/angular-native.md), or [Capacitor](./apps/docs/src/content/guides/capacitor.md). For a coding agent, run `npx @pangular-inspector/devtools mcp`. ## Documentation Read the docs at [pangular-inspector.dev](https://pangular-inspector.dev). Their source lives in [`apps/docs`](./apps/docs); run them locally with `pnpm docs:dev`. +- [Quick start](./apps/docs/src/content/getting-started/quick-start.md) - [Getting started](./apps/docs/src/content/getting-started/introduction.md) - [Inspectors](./apps/docs/src/content/inspectors/dashboard.md) - [Agent tools](./apps/docs/src/content/agents/mcp-server.md) diff --git a/apps/docs/public/images/panel/analog.png b/apps/docs/public/images/panel/analog.png new file mode 100644 index 00000000..67545707 Binary files /dev/null and b/apps/docs/public/images/panel/analog.png differ diff --git a/apps/docs/public/images/panel/authorize.png b/apps/docs/public/images/panel/authorize.png new file mode 100644 index 00000000..0b703b14 Binary files /dev/null and b/apps/docs/public/images/panel/authorize.png differ diff --git a/apps/docs/public/images/panel/capacitor.png b/apps/docs/public/images/panel/capacitor.png new file mode 100644 index 00000000..cba85a95 Binary files /dev/null and b/apps/docs/public/images/panel/capacitor.png differ diff --git a/apps/docs/public/images/panel/command-palette-hub.png b/apps/docs/public/images/panel/command-palette-hub.png new file mode 100644 index 00000000..20effa7c Binary files /dev/null and b/apps/docs/public/images/panel/command-palette-hub.png differ diff --git a/apps/docs/public/images/panel/command-palette.png b/apps/docs/public/images/panel/command-palette.png new file mode 100644 index 00000000..9c2d656d Binary files /dev/null and b/apps/docs/public/images/panel/command-palette.png differ diff --git a/apps/docs/public/images/panel/components.png b/apps/docs/public/images/panel/components.png new file mode 100644 index 00000000..92698cca Binary files /dev/null and b/apps/docs/public/images/panel/components.png differ diff --git a/apps/docs/public/images/panel/dashboard.png b/apps/docs/public/images/panel/dashboard.png new file mode 100644 index 00000000..00a621d8 Binary files /dev/null and b/apps/docs/public/images/panel/dashboard.png differ diff --git a/apps/docs/public/images/panel/errors.png b/apps/docs/public/images/panel/errors.png new file mode 100644 index 00000000..cc506f03 Binary files /dev/null and b/apps/docs/public/images/panel/errors.png differ diff --git a/apps/docs/public/images/panel/forms.png b/apps/docs/public/images/panel/forms.png new file mode 100644 index 00000000..8503f018 Binary files /dev/null and b/apps/docs/public/images/panel/forms.png differ diff --git a/apps/docs/public/images/panel/injectors.png b/apps/docs/public/images/panel/injectors.png new file mode 100644 index 00000000..058f4389 Binary files /dev/null and b/apps/docs/public/images/panel/injectors.png differ diff --git a/apps/docs/public/images/panel/ngrx-store.png b/apps/docs/public/images/panel/ngrx-store.png new file mode 100644 index 00000000..558326ef Binary files /dev/null and b/apps/docs/public/images/panel/ngrx-store.png differ diff --git a/apps/docs/public/images/panel/pipes.png b/apps/docs/public/images/panel/pipes.png new file mode 100644 index 00000000..2f6cde4f Binary files /dev/null and b/apps/docs/public/images/panel/pipes.png differ diff --git a/apps/docs/public/images/panel/popup.png b/apps/docs/public/images/panel/popup.png new file mode 100644 index 00000000..ea76a4c2 Binary files /dev/null and b/apps/docs/public/images/panel/popup.png differ diff --git a/apps/docs/public/images/panel/router.png b/apps/docs/public/images/panel/router.png new file mode 100644 index 00000000..dbfc5f2f Binary files /dev/null and b/apps/docs/public/images/panel/router.png differ diff --git a/apps/docs/public/images/panel/settings.png b/apps/docs/public/images/panel/settings.png new file mode 100644 index 00000000..3151a0ec Binary files /dev/null and b/apps/docs/public/images/panel/settings.png differ diff --git a/apps/docs/public/images/panel/signals.png b/apps/docs/public/images/panel/signals.png new file mode 100644 index 00000000..334b1215 Binary files /dev/null and b/apps/docs/public/images/panel/signals.png differ diff --git a/apps/docs/public/images/panel/ssr-http.png b/apps/docs/public/images/panel/ssr-http.png new file mode 100644 index 00000000..114ebd67 Binary files /dev/null and b/apps/docs/public/images/panel/ssr-http.png differ diff --git a/apps/docs/public/images/panel/timeline.png b/apps/docs/public/images/panel/timeline.png new file mode 100644 index 00000000..fcc5426c Binary files /dev/null and b/apps/docs/public/images/panel/timeline.png differ diff --git a/apps/docs/src/app/pages/index.page.ts b/apps/docs/src/app/pages/index.page.ts index 5ab95a87..94982d9c 100644 --- a/apps/docs/src/app/pages/index.page.ts +++ b/apps/docs/src/app/pages/index.page.ts @@ -169,7 +169,7 @@ interface StackItem { class="mt-8 flex flex-wrap items-center justify-center gap-x-2 gap-y-3 sm:mt-10 lg:justify-start" > Get started @@ -582,10 +582,10 @@ interface StackItem { diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md index 26bf1942..5b9d2349 100644 --- a/apps/docs/src/content/contributing/writing-docs.md +++ b/apps/docs/src/content/contributing/writing-docs.md @@ -154,6 +154,18 @@ Card icons come from a fixed set: `book`, `box`, `code`, `compass`, `file`, `lay Write `*Angular`, `*Analog`, `*Devframe`, `*NgRx`, `*MCP` or `*Vite` in prose to link the word to its site. The list is `keywords` in `ngmd.config.ts`. The asterisk is not a typo, so don't remove it. +### Panel screenshots + +The inspector pages show the panel with ``, from `apps/docs/public/images/panel`. `scripts/docs-screenshots.mjs` builds the demo app, serves it on a free port, opens each tab in Chromium and saves one PNG per tab, at 1280 by 800 pixels in the light theme. The Analog shot comes from the Analog demo. Run it again after you change a tab: + +```bash +# Every screenshot, or only the ones you name +node scripts/docs-screenshots.mjs +node scripts/docs-screenshots.mjs --only components,signals +``` + +`--list` prints the names, and `--chrome ` picks the Chromium executable. Give each image an `alt` that says what the screenshot shows. + ## Code samples ### Fences diff --git a/apps/docs/src/content/getting-started/command-palette.md b/apps/docs/src/content/getting-started/command-palette.md index 919820db..21f3baed 100644 --- a/apps/docs/src/content/getting-started/command-palette.md +++ b/apps/docs/src/content/getting-started/command-palette.md @@ -11,6 +11,8 @@ description: Open any tab and run the main panel actions from the keyboard with The command palette lists a command for every tab and for the main actions of the panel. Type a few letters to filter it, then press Enter. + + ## Open it | Where you are | What Cmd+K or Ctrl+K opens | @@ -55,6 +57,8 @@ The palette is a modal dialog. The filter box is a combobox that controls the li In the hub, the Pangular commands are in the hub's command palette, in the **Pangular Inspector** category. **Pangular: open a tab** groups the tab commands. A **Go to** or **Components** command first opens the **All tools** dock, so the panel is on screen. + + The server registers the commands through Devframe's command registry when the hub starts, and each one asks the open panels to run it. A panel in a background browser tab ignores it. If you have the hub open in two windows, both run the command. The **Popup** commands stay out of the hub's palette, because the hub has its own dock modes. With the [terminals dock](./terminals.md) on, the hub's palette also has **Pangular: run <command>** and **Pangular: stop <command>** for the build and test commands, such as **Pangular: run pnpm run build**. The server runs these itself, so they work without a panel open. They are not in the panel's own palette. diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index 82cc147f..212e558d 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -28,7 +28,7 @@ In an *Angular app with server-side rendering, the devtools run inside your Expr Import the overlay in main.ts, in development only. - Start the app in development mode and click the amber button in the corner of the page. + Start the app in development mode and click the Pangular Inspector button in the corner of the page. @@ -201,13 +201,13 @@ When you run `ng serve`, the Angular dev server runs `server.ts` too, so the hub ```bash group="run" name="ng serve" image="https://cdn.simpleicons.org/angular/DD0031" active ng serve -# open http://localhost:4200 and click the amber button +# open http://localhost:4200 and click the Pangular Inspector button ``` ```bash group="run" name="SSR server" image="https://cdn.simpleicons.org/nodedotjs/5FA04E" ng build --configuration development node dist//server/server.mjs -# open http://localhost:4000 and click the amber button +# open http://localhost:4000 and click the Pangular Inspector button ``` ### With the built SSR server diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index 71ec2d3a..01092383 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -223,7 +223,7 @@ The [Chrome extension](./chrome-extension.md) adds a panel to Chrome DevTools. I Run ng serve for an Angular CLI app, or the Vite dev server for an Analog app. For a client-only app, keep pangular dev running next to ng serve. - An amber button appears in the bottom-right corner of the page. The overlay adds it. + The Pangular Inspector button appears in the bottom-right corner of the page. The overlay adds it. Click the button. The header shows Live once the panel is connected. diff --git a/apps/docs/src/content/getting-started/introduction.md b/apps/docs/src/content/getting-started/introduction.md index 9c298937..fb8c17b3 100644 --- a/apps/docs/src/content/getting-started/introduction.md +++ b/apps/docs/src/content/getting-started/introduction.md @@ -147,8 +147,8 @@ The devtools are a - - Add the package and pick how you want to run it. + + From install to a live panel in an Angular CLI app with SSR. Mount the devtools in the Express server of an SSR app. diff --git a/apps/docs/src/content/getting-started/quick-start.md b/apps/docs/src/content/getting-started/quick-start.md new file mode 100644 index 00000000..5e308a7b --- /dev/null +++ b/apps/docs/src/content/getting-started/quick-start.md @@ -0,0 +1,151 @@ +--- +title: Quick start +description: Add the devtools to an Angular CLI app with server-side rendering and open the panel on your page. +--- + + + From install to a live panel on your page, in an Angular CLI app with server-side rendering. + + +# Quick start + +This page takes one path: an *Angular CLI app with SSR, run with `ng serve`. Four steps take you from install to a live panel. The devtools mount in the Express server that the CLI generated, so there is one process to run and nothing to proxy. If your app is set up another way, see [Other ways to set it up](#other-ways-to-set-it-up). + +## Before you start + +You need an Angular CLI app with SSR, on Angular 20 or later, and Node.js 22 or later. An SSR app has a `src/server.ts`. To create one, run `ng new my-app --ssr`. + + + A client-only app has no server to mount the devtools in. Follow Client-only Angular CLI app instead. + + +## Install the package + +Install it as a dependency, not a dev dependency, because `server.ts` imports it when the server starts. + +```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active +pnpm add @pangular-inspector/devtools +``` + +```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837" +npm install @pangular-inspector/devtools +``` + +```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB" +yarn add @pangular-inspector/devtools +``` + +```bash group="install" name="bun" image="https://bun.sh/logo.svg" +bun add @pangular-inspector/devtools +``` + +## Mount the hub + +Open `src/server.ts`. Import `initPangularHub` and mount its middleware right after `const app = express()`, before the generated `express.static` and Angular handlers: + +```ts {10,16-18} +// src/server.ts +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; +import express from 'express'; +import {join} from 'node:path'; +import {initPangularHub} from '@pangular-inspector/devtools/hub'; + +const browserDistFolder = join(import.meta.dirname, '../browser'); + +const app = express(); +const angularApp = new AngularNodeAppEngine(); +// Before express.static and the Angular handler, so the devtools routes answer first. +const devtools = initPangularHub({ws: false}); +app.use(devtools.nodeMiddleware); +``` + +Leave the rest of the file as it is. `ws: false` connects the browser over server-sent events on the same port, which works under `ng serve`. When `NODE_ENV` is `production`, the hub is off and the middleware only calls `next()`. + +## Load the overlay + +The [overlay](./overlay.md) reads the running page and adds the button that opens the panel. Import it after bootstrap, in development only: + +```ts {7-12} +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + +bootstrapApplication(App, appConfig) + .then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('@pangular-inspector/devtools/overlay'); + } + return undefined; + }) + .catch((err) => console.error(err)); +``` + +`ngDevMode` is false in production builds, so the import never runs there and the overlay stays out of your production bundle. + +## Open the panel + +### Start the app + +```bash +ng serve +``` + +Open `http://localhost:4200`. A round button with the Pangular Inspector logo appears in the bottom-right corner of the page. Click it, or press Alt+Shift+P. + +### Enter the one-time code + +The first time the panel opens, it shows **Authorize Pangular Inspector** and asks for a 6-digit code. The terminal that runs `ng serve` prints the code in a **Pangular Inspector** box, next to **auth code**. Type it and click **Authorize**. + + + +The hub asks for the code by default. [Access and redaction](../security.md) covers what it protects. + +### Check that it works + +The panel opens on the [Dashboard](../inspectors/dashboard.md), and the header shows **Live**. The tabs now read your running app. + + + +To see the panel on a page of its own, open `http://localhost:4200/__devframes/`. + +## Troubleshooting + + + + The app is probably a production build. ng build uses the production configuration by default, and the overlay import never runs there. Use ng serve, or build with --configuration development. Then check that main.ts imports the overlay. + + + The page could not reach the hub. Check that app.use(devtools.nodeMiddleware) comes before express.static and the Angular handler in server.ts. + + + Check that you entered the code from the terminal, and that the header shows Live. A browser reads data only after it exchanges the code. + + + +## Other ways to set it up + +| Your app or goal | Follow | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| An Angular CLI app without SSR | [Client-only Angular CLI app](./installation.md#client-only-angular-cli-app) | +| An Analog app or another Vite-based app | [Vite and Analog](./vite.md) | +| SSR on Hono, h3 or Fastify | [Hono, h3 and Fastify](./other-servers.md) | +| More hub options, SSR tracing and production | [Angular CLI and Express](./express.md) | +| Your source files only, with no app running | [Standalone CLI](./cli.md) | +| The panel inside Chrome DevTools | [Chrome extension](./chrome-extension.md) | +| A coding agent | [MCP server](../agents/mcp-server.md) | +| An Angular Native, NativeScript or Capacitor app | [Angular Native](./angular-native.md), [NativeScript](./nativescript.md), [Set up Capacitor](../guides/capacitor.md) | + +## Where to next + + + + + + + diff --git a/apps/docs/src/content/getting-started/settings.md b/apps/docs/src/content/getting-started/settings.md index 260797dd..2209b8fb 100644 --- a/apps/docs/src/content/getting-started/settings.md +++ b/apps/docs/src/content/getting-started/settings.md @@ -11,6 +11,8 @@ description: Pick the panel theme, hide, pin and reorder tabs, and hide the popu Open **Settings** with the gear button in the panel header, or with **Open settings** in the [command palette](./command-palette.md). Press the gear button again to go back to the tab you had open. The page is also at `#tab=settings`. + + ## Where settings are saved Settings belong to you, not to the project. The panel saves them in the browser's `localStorage`, so every panel on the same address uses them, and they survive reloads. The Chrome extension and the panel on your dev server have different addresses, so each keeps its own. diff --git a/apps/docs/src/content/inspectors/analog.md b/apps/docs/src/content/inspectors/analog.md index 3b6e3626..f110725e 100644 --- a/apps/docs/src/content/inspectors/analog.md +++ b/apps/docs/src/content/inspectors/analog.md @@ -13,6 +13,8 @@ The Analog tab reads an *Analog app from three sides: its files, its dev server, The Analog dock is always in the rail. In other apps it shows a **This app doesn’t use Analog** page. Its tooltip in the rail then reads **Analog: not an Analog app**. Without the hub, the Analog tab appears only in Analog apps. + + ## Setup ### Add the plugin diff --git a/apps/docs/src/content/inspectors/capacitor.md b/apps/docs/src/content/inspectors/capacitor.md index 5d40c8ac..df59942f 100644 --- a/apps/docs/src/content/inspectors/capacitor.md +++ b/apps/docs/src/content/inspectors/capacitor.md @@ -13,6 +13,8 @@ The Capacitor tab shows a Capacitor app from the inside: its platform and enviro The tab appears in the panel while a Capacitor app reports. In the hub, the **Capacitor** dock shows it, and shows the setup steps while no app reports. While no app reports, the dock's tooltip in the rail reads **Capacitor: no app connected**. + + ## What it shows ### Summary diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md index 0e919db2..49498617 100644 --- a/apps/docs/src/content/inspectors/components.md +++ b/apps/docs/src/content/inspectors/components.md @@ -11,6 +11,8 @@ description: Every component instance on the page, with live inputs, properties, The Components tab lists each rendered component instance as a tree. It walks each app root in document order, including shadow roots, then the components outside the app root, such as overlays. When no page is connected, it lists what your source declares instead. + + ## What it shows ### The tree diff --git a/apps/docs/src/content/inspectors/dashboard.md b/apps/docs/src/content/inspectors/dashboard.md index 0a5dbf5d..394983e9 100644 --- a/apps/docs/src/content/inspectors/dashboard.md +++ b/apps/docs/src/content/inspectors/dashboard.md @@ -11,6 +11,8 @@ description: Project metadata and a count for each inspector. The Dashboard opens by default. The top block describes your workspace. The cards below count what each inspector found, and each card opens its tab. + + ## What it shows ### Project block diff --git a/apps/docs/src/content/inspectors/errors.md b/apps/docs/src/content/inspectors/errors.md index 6a6e44e2..ffe86b8f 100644 --- a/apps/docs/src/content/inspectors/errors.md +++ b/apps/docs/src/content/inspectors/errors.md @@ -11,6 +11,8 @@ description: Errors from Angular's ErrorHandler, uncaught errors, unhandled prom The Errors tab collects what goes wrong on the running page: errors that reach Angular's `ErrorHandler`, uncaught errors and unhandled promise rejections. It groups them by message, links Angular error codes to their reference page, and points at the component that threw when it can find it. A second view shows the page's console calls. + + ## What it shows ### Errors diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index 0a72308a..07b06133 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -11,6 +11,8 @@ description: Every form on the page with each field's state and errors, a change The Forms tab reads the forms of the running page, in development builds only. It covers Signal Forms, reactive forms and template-driven forms. Actions you run from the tab go back to the page and run there. Forms inside open shadow roots, such as `ViewEncapsulation.ShadowDom` components, are included. + + ## What it shows ### Forms list diff --git a/apps/docs/src/content/inspectors/injectors.md b/apps/docs/src/content/inspectors/injectors.md index ebcbc763..07779a20 100644 --- a/apps/docs/src/content/inspectors/injectors.md +++ b/apps/docs/src/content/inspectors/injectors.md @@ -11,6 +11,8 @@ description: The injector hierarchy, token lookup paths and the providers at eac When a component asks for a token, Angular walks up the element injectors, then through the environment injectors, until something provides it. The Injectors tab shows that tree. Without a live page, it lists the DI found in your source. + + ## What it shows ### View switch diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md index 013bdfb9..eb6c3fdc 100644 --- a/apps/docs/src/content/inspectors/ngrx-store.md +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -13,6 +13,8 @@ The Store tab shows your *NgRx state live. It covers `@ngrx/signals` stores (`si The tab has two sections. **Live stores** reads the running page. **Source declarations** lists what your files declare. + + ## What it shows ### Store list diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md index 95e533ec..229e4266 100644 --- a/apps/docs/src/content/inspectors/pipes.md +++ b/apps/docs/src/content/inspectors/pipes.md @@ -11,6 +11,8 @@ description: Custom, built-in and library pipes, where they are used, live insta The Pipes tab lists your `@Pipe` classes, the built-in pipes from `@angular/common` that your templates use, and pipes from libraries that the page uses. It shows the live instances on the page, and records calls when you ask it to. + + ## What it shows ### Pipe list diff --git a/apps/docs/src/content/inspectors/router.md b/apps/docs/src/content/inspectors/router.md index 7d3d9e82..d7c9418e 100644 --- a/apps/docs/src/content/inspectors/router.md +++ b/apps/docs/src/content/inspectors/router.md @@ -11,6 +11,8 @@ description: The live route, every navigation as a story, the live route config, The Routes tab reads the running app's Router. The top section, **Live router**, has five views. The bottom section, **Source route config**, lists the routes your files declare. When more than one page is connected, a **Page** picker chooses which one you see. + + ## What it shows ### Current diff --git a/apps/docs/src/content/inspectors/signals.md b/apps/docs/src/content/inspectors/signals.md index 2b52ca98..8cb86401 100644 --- a/apps/docs/src/content/inspectors/signals.md +++ b/apps/docs/src/content/inspectors/signals.md @@ -11,6 +11,8 @@ description: The live signal graph of one component or injector, drawn with its The Signals tab shows the reactive graph of one component, or of the root or a route injector, at a time. Only signals that a template or an effect has read appear. A signal nothing has read yet is not part of the graph. Without a live page, the tab lists the signal declarations in your source. + + ## What it shows ### Page picker diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index a58f0979..db9afa06 100644 --- a/apps/docs/src/content/inspectors/ssr-http.md +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -11,6 +11,8 @@ description: An HTTP timeline for SSR and client calls, fault injection, hydrati The SSR & HTTP tab shows the HTTP calls your app makes during server rendering and in the browser. It also shows the hydration result and the TransferState payload, and it can inject faults into requests. Pick the page at the top. The tab shows that page's data. + + ## Setup The timeline and fault rules need the interceptor. The hydration warnings need the provider. Add both to the app config, with `withPangular()` before your own interceptors: diff --git a/apps/docs/src/content/inspectors/timeline.md b/apps/docs/src/content/inspectors/timeline.md index 050813d8..1918e956 100644 --- a/apps/docs/src/content/inspectors/timeline.md +++ b/apps/docs/src/content/inspectors/timeline.md @@ -11,6 +11,8 @@ description: Navigations, HTTP calls, NgRx actions, change detection cycles, for The Timeline tab puts the events that other tabs already record on one time axis. Each source is a layer with its own lane. Turn layers on and off, pick a range, and open any event in the tab that shows it in full. + + ## What it shows ### Layers diff --git a/apps/docs/src/ngmd.config.ts b/apps/docs/src/ngmd.config.ts index 91e3d621..f5f526b7 100644 --- a/apps/docs/src/ngmd.config.ts +++ b/apps/docs/src/ngmd.config.ts @@ -175,6 +175,7 @@ const config: NgmdConfig = { { label: 'Getting Started', items: [ + {label: 'Quick start', href: '/getting-started/quick-start', status: 'new'}, {label: 'Introduction', href: '/getting-started/introduction'}, {label: 'Installation', href: '/getting-started/installation'}, {label: 'Angular CLI and Express', href: '/getting-started/express'}, diff --git a/scripts/docs-screenshots.mjs b/scripts/docs-screenshots.mjs new file mode 100644 index 00000000..cba8aabc --- /dev/null +++ b/scripts/docs-screenshots.mjs @@ -0,0 +1,537 @@ +#!/usr/bin/env node +// Captures the panel screenshots on the docs pages, from the demo apps. +// +// It builds the SSR demo in development mode, serves it on a free port (the +// way "Serving Locally" in AGENTS.md does), opens a demo page and the panel in +// Chromium, and saves one PNG per view in apps/docs/public/images/panel. The +// Analog shot comes from the Analog demo's Vite dev server. The Capacitor shot +// runs the demo with a stand-in Android bridge, since no device reports here. +// Servers and the build output it created are removed when it ends. +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { join, resolve } from 'node:path'; +import { chromium } from 'playwright'; + +const USAGE = `Usage: node scripts/docs-screenshots.mjs [options] + +Options: + --only Comma-separated shot names (default: every shot) + --out Output folder (default apps/docs/public/images/panel) + --chrome Chromium executable (default: Playwright's own, or $CHROME_PATH) + --skip-build Use the existing dist/ build of the demo + --keep-build Keep the build output this script created + --list Print the shot names and exit`; + +const args = process.argv.slice(2); +const flag = (name) => args.includes(name); +const option = (name, fallback) => { + const index = args.indexOf(name); + return index === -1 ? fallback : args[index + 1]; +}; +if (flag('--help')) { + console.log(USAGE); + process.exit(0); +} + +const root = resolve(import.meta.dirname, '..'); +const outDir = resolve(root, option('--out', 'apps/docs/public/images/panel')); +const chrome = option('--chrome', process.env.CHROME_PATH); +const VIEWPORT = { width: 1280, height: 800 }; +const PANEL = '/__devframes/pangular/'; + +/** Waits until the page settles, then a little longer for the panel to receive pushes. */ +async function settle(page, ms = 1500) { + await page.waitForLoadState('networkidle').catch(() => {}); + await page.waitForTimeout(ms); +} + +/** Clicks a link or button in the demo app by its accessible name. */ +async function click(page, role, name) { + await page.getByRole(role, { name, exact: true }).first().click(); + await settle(page, 600); +} + +/** + * A minimal Android bridge, so the Capacitor tab has calls to show. The + * overlay wraps `nativePromise` and `nativeCallback` the way it does in a real + * WebView, and every call below goes through that wrapper. + */ +function capacitorBridge() { + const answers = { + 'Camera.getPhoto': () => ({ format: 'jpeg', webPath: 'blob:trip-photo' }), + 'Camera.pickLimitedLibraryPhotos': () => { + throw Object.assign(new Error('Not implemented on Android'), { code: 'UNIMPLEMENTED' }); + }, + 'Preferences.get': () => ({ value: 'trips' }), + 'Network.getStatus': () => ({ connected: true, connectionType: 'wifi' }), + 'Device.getInfo': () => ({ platform: 'android', model: 'Pixel 9', osVersion: '15' }), + }; + const headers = [ + { name: 'Camera', methods: [{ name: 'getPhoto' }, { name: 'pickLimitedLibraryPhotos' }] }, + { name: 'Preferences', methods: [{ name: 'get' }, { name: 'set' }] }, + { name: 'Network', methods: [{ name: 'getStatus' }, { name: 'addListener' }] }, + { name: 'Device', methods: [{ name: 'getInfo' }] }, + { name: 'Geolocation', methods: [{ name: 'watchPosition' }] }, + ]; + window.Capacitor = { + DEBUG: true, + isLoggingEnabled: true, + getPlatform: () => 'android', + isNativePlatform: () => true, + isPluginAvailable: (name) => headers.some((header) => header.name === name), + getServerUrl: () => 'https://localhost', + PluginHeaders: headers, + Plugins: Object.fromEntries(headers.map((header) => [header.name, {}])), + nativePromise(plugin, method) { + const answer = answers[`${plugin}.${method}`]; + return new Promise((done, fail) => + setTimeout( + () => { + try { + done(answer ? answer() : {}); + } catch (error) { + fail(error); + } + }, + method === 'getPhoto' ? 640 : 40, + ), + ); + }, + nativeCallback(plugin, method, options, callback) { + let ticks = 0; + const timer = setInterval(() => { + callback({ coords: { latitude: 48.8566, longitude: 2.3522, accuracy: 12 } }); + if (++ticks === 3) clearInterval(timer); + }, 200); + return `${plugin}-${method}`; + }, + }; +} + +const SHOTS = [ + { + // The quick start: the popup open on the demo page. + name: 'popup', + app: '/examples/components', + async inPanel(app) { + await app.getByRole('button', { name: 'Toggle Pangular Inspector' }).click(); + await app.waitForTimeout(3000); + }, + }, + { + // The quick start: the one-time code prompt, with the code on (the default). + name: 'authorize', + server: 'ssr-auth', + app: '/examples/components', + async inPanel(app) { + await app.getByRole('button', { name: 'Toggle Pangular Inspector' }).click(); + await app.waitForTimeout(3000); + }, + }, + { + name: 'dashboard', + app: '/examples', + tab: 'dashboard', + }, + { + name: 'timeline', + app: '/examples/signals', + tab: 'timeline', + async prepare(app) { + await click(app, 'button', 'Add one'); + await click(app, 'button', 'Add five'); + await click(app, 'link', 'Routes'); + await click(app, 'link', 'Details'); + await click(app, 'link', 'SSR & HTTP'); + await click(app, 'link', 'NgRx'); + await click(app, 'button', 'Add an item'); + await click(app, 'button', 'Add an item'); + }, + async inPanel(panel) { + await panel.locator('button.row').filter({ hasText: 'NgRx' }).first().click(); + }, + }, + { + name: 'components', + app: '/examples/components', + tab: 'components', + async inPanel(panel) { + const hosts = panel.getByRole('button', { name: 'Directive hosts' }); + if ((await hosts.getAttribute('aria-pressed')) === 'true') await hosts.click(); + await panel.getByText('ComponentsExample', { exact: true }).first().click(); + await panel.evaluate(() => window.scrollTo(0, 0)); + }, + }, + { + name: 'injectors', + app: '/examples/di', + tab: 'injectors', + async inPanel(panel) { + await panel.getByText('', { exact: true }).first().click(); + }, + }, + { + name: 'signals', + app: '/examples/signals', + tab: 'signals', + async prepare(app) { + await click(app, 'button', 'Add one'); + await click(app, 'button', 'Add five'); + }, + }, + { + name: 'ngrx-store', + app: '/examples/store', + tab: 'store', + async prepare(app) { + await click(app, 'button', 'Add an item'); + await click(app, 'button', 'Add an item'); + }, + async inPanel(panel) { + await panel.getByText('PackingStore', { exact: true }).first().click(); + }, + }, + { + name: 'forms', + app: '/examples/forms', + tab: 'forms', + }, + { + name: 'router', + app: '/examples/routes/summary', + tab: 'routes', + async prepare(app) { + await click(app, 'link', 'Details'); + await click(app, 'link', 'User 7'); + }, + }, + { + name: 'pipes', + app: '/examples/pipes', + tab: 'pipes', + }, + { + name: 'ssr-http', + app: '/examples/http', + tab: 'network', + }, + { + name: 'errors', + app: '/examples/routes/summary', + tab: 'errors', + async prepare(app) { + await click(app, 'link', 'Broken'); + await click(app, 'link', 'Summary'); + await click(app, 'link', 'Broken'); + await click(app, 'link', 'Guard loop'); + }, + async inPanel(panel) { + await panel.locator('ol.errors details summary').first().click(); + }, + }, + { + name: 'capacitor', + app: '/examples', + tab: 'capacitor', + init: capacitorBridge, + // What an Android WebView sends, so the Environment block reads like a device. + userAgent: + 'Mozilla/5.0 (Linux; Android 15; Pixel 9 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/141.0.0.0 Mobile Safari/537.36', + async prepare(app) { + await app.evaluate(async () => { + const cap = window.Capacitor; + const call = (plugin, method, options) => + cap.nativePromise(plugin, method, options).catch(() => {}); + await call('Device', 'getInfo'); + await call('Network', 'getStatus'); + await call('Preferences', 'get', { key: 'lastSearch' }); + await call('Camera', 'getPhoto', { quality: 90, resultType: 'uri' }); + await call('Camera', 'pickLimitedLibraryPhotos'); + cap.nativeCallback('Geolocation', 'watchPosition', { enableHighAccuracy: true }, () => {}); + }); + await settle(app, 1500); + }, + async inPanel(panel) { + await panel.getByText('Camera.getPhoto()', { exact: true }).first().click(); + }, + }, + { + name: 'command-palette', + app: '/examples', + tab: 'dashboard', + async inPanel(panel) { + await panel.keyboard.press('Control+K'); + await panel.getByRole('combobox').first().waitFor(); + }, + }, + { + name: 'command-palette-hub', + app: '/examples', + panel: '/__devframes/', + async inPanel(panel) { + await panel.keyboard.press('Control+K'); + await panel.waitForTimeout(800); + await panel.keyboard.type('pangular'); + }, + }, + { + name: 'settings', + app: '/examples', + tab: 'settings', + }, + { + name: 'analog', + server: 'analog', + app: '/', + tab: 'analog', + signIn: true, + }, +]; + +const selected = option('--only', '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +if (flag('--list')) { + console.log(SHOTS.map((shot) => shot.name).join('\n')); + process.exit(0); +} +const unknown = selected.filter((name) => !SHOTS.some((shot) => shot.name === name)); +if (unknown.length) { + console.error(`Unknown shot: ${unknown.join(', ')}\n\n${USAGE}`); + process.exit(1); +} +const shots = SHOTS.filter((shot) => !selected.length || selected.includes(shot.name)); + +function freePort() { + return new Promise((done, fail) => { + const server = createServer(); + server.once('error', fail); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => done(port)); + }); + }); +} + +async function waitForUrl(url, child, timeout = 120_000) { + const until = Date.now() + timeout; + while (Date.now() < until) { + if (child.exitCode !== null) throw new Error(`${url}: the server exited (${child.exitCode})`); + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Not listening yet. + } + await new Promise((done) => setTimeout(done, 500)); + } + throw new Error(`${url} did not answer within ${timeout / 1000}s`); +} + +const children = []; +function start(command, commandArgs, options) { + const child = spawn(command, commandArgs, { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + ...options, + }); + let log = ''; + const keep = (chunk) => { + log = (log + chunk).slice(-4000); + }; + child.stdout.on('data', keep); + child.stderr.on('data', keep); + child.log = () => log; + children.push(child); + return child; +} + +/** Stops every server this script started, and waits for them to exit. */ +async function stopAll() { + const running = children.filter((child) => child.exitCode === null && child.signalCode === null); + const exited = running.map( + (child) => + new Promise((done) => { + child.once('exit', done); + setTimeout(done, 10_000); + }), + ); + for (const child of running) { + try { + // The process group, so Vite's workers stop too. + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } + await Promise.all(exited); +} +process.on('SIGINT', () => { + stopAll().finally(() => process.exit(130)); +}); + +/** + * Starts the SSR demo with the devtools hub on a free port. The hub is off in + * production, and the demo turns the one-time code off unless PANGULAR_AUTH is true. + */ +async function startSsr(auth = false) { + const port = await freePort(); + const env = { ...process.env, PORT: String(port) }; + delete env.NODE_ENV; + delete env.PANGULAR_AUTH; + if (auth) env.PANGULAR_AUTH = 'true'; + const child = start('node', ['dist/pangular-inspector/server/server.mjs'], { env }); + const base = `http://localhost:${port}`; + try { + await waitForUrl(`${base}/examples`, child); + } catch (error) { + console.error(child.log()); + throw error; + } + return { base, child }; +} + +/** Starts the Analog demo's Vite dev server on a free port. */ +async function startAnalog() { + const port = await freePort(); + const child = start( + 'pnpm', + ['exec', 'vite', '--port', String(port), '--strictPort', '--host', 'localhost'], + { cwd: join(root, 'examples/analog'), env: { ...process.env } }, + ); + const base = `http://localhost:${port}`; + try { + await waitForUrl(`${base}/`, child, 180_000); + } catch (error) { + console.error(child.log()); + throw error; + } + return { base, child }; +} + +/** + * The Vite plugin keeps the one-time code on. The overlay's connection makes + * the server print a code, and the panel exchanges it the way the printed + * link does, through `#devframe_otp=`. + */ +async function oneTimeCode(child) { + const until = Date.now() + 30_000; + while (Date.now() < until) { + // Strip the terminal colors around the code. + const log = child.log().replace(/\x1b\[[0-9;]*m/g, ''); + const codes = [...log.matchAll(/devframe_otp=(\d+)/g)]; + if (codes.length) return codes.at(-1)[1]; + await new Promise((done) => setTimeout(done, 500)); + } + console.warn('No one-time code printed; the panel may stay empty.'); + return undefined; +} + +/** Shrinks the PNG when pngquant or oxipng is installed. */ +function optimize(file) { + const has = (tool) => { + try { + execFileSync('which', [tool], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + }; + if (has('pngquant')) { + execFileSync('pngquant', [ + '--force', + '--skip-if-larger', + '--quality=80-95', + '--output', + file, + file, + ]); + } + if (has('oxipng')) execFileSync('oxipng', ['-q', '-o', '4', '--strip', 'safe', file]); +} + +async function capture(browser, base, shot, server) { + const context = await browser.newContext({ + viewport: VIEWPORT, + deviceScaleFactor: 1, + colorScheme: 'light', + reducedMotion: 'reduce', + ...(shot.userAgent ? { userAgent: shot.userAgent } : {}), + }); + try { + const app = await context.newPage(); + if (shot.init) await app.addInitScript(shot.init); + await app.goto(`${base}${shot.app}`); + await settle(app); + const code = shot.signIn ? await oneTimeCode(server) : undefined; + if (shot.prepare) await shot.prepare(app); + + let target = app; + if (shot.tab || shot.panel) { + target = await context.newPage(); + const hash = `${code ? `devframe_otp=${code}&` : ''}tab=${shot.tab}`; + await target.goto(shot.panel ? `${base}${shot.panel}` : `${base}${PANEL}#${hash}`); + // In the hub, the panel sits in a frame. + const status = shot.panel + ? target.frameLocator('iframe').first().locator('.status.connected') + : target.locator('.status.connected'); + await status + .waitFor({ timeout: 20_000 }) + .catch(() => console.warn(`${shot.name}: the panel did not report Live`)); + await settle(target, 2500); + } + if (shot.inPanel) { + await shot.inPanel(target); + await settle(target, 1200); + } + const file = join(outDir, `${shot.name}.png`); + await target.screenshot({ path: file }); + optimize(file); + console.log(`wrote ${file} (${Math.round(statSync(file).size / 1024)} KB)`); + } finally { + await context.close(); + } +} + +const SERVERS = { + ssr: () => startSsr(), + 'ssr-auth': () => startSsr(true), + analog: startAnalog, +}; + +// Output folders the build and the dev servers create. The ones missing now are removed at the end. +// With --skip-build this run creates none of them, so it removes none. +const built = flag('--skip-build') + ? [] + : ['dist', 'packages/devtools/dist', 'examples/analog/dist'].filter( + (dir) => !existsSync(join(root, dir)), + ); +if (!flag('--skip-build')) { + // Builds the package (with the panel) and the demo, in development mode so ngDevMode is on. + execFileSync('pnpm', ['build', '--configuration', 'development'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, NX_DAEMON: 'false' }, + }); +} + +mkdirSync(outDir, { recursive: true }); +const browser = await chromium.launch(chrome ? { executablePath: chrome } : {}); +try { + for (const [name, startServer] of Object.entries(SERVERS)) { + const list = shots.filter((shot) => (shot.server ?? 'ssr') === name); + if (!list.length) continue; + const { base, child } = await startServer(); + for (const shot of list) await capture(browser, base, shot, child); + await stopAll(); + } +} finally { + await browser.close(); + await stopAll(); + if (!flag('--keep-build')) { + for (const dir of built) rmSync(join(root, dir), { recursive: true, force: true }); + } +}