diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md
index 02535d98..55d3a350 100644
--- a/.claude/skills/devtools-docs/SKILL.md
+++ b/.claude/skills/devtools-docs/SKILL.md
@@ -94,7 +94,7 @@ Raw HTML in markdown. Always write explicit closing tags; never self-close custo
| `ngmd-workflow` > `ngmd-step` | Ordered steps. | step `title` |
| `ngmd-accordion` > `ngmd-accordion-item` | FAQ. | item `title`, `open` |
| `ngmd-pill-row` > `ngmd-pill` | Related links at the end of a page. | pill `href`, `title` |
-| `ngmd-badge` | Status chip next to a heading. | `variant`: new, updated, alpha, beta, stable, deprecated |
+| `ngmd-badge` | Status chip next to a heading. | `variant`: new, updated, alpha, beta, stable, deprecated, action, destructive |
| `ngmd-tabs` > `ngmd-tab` | Alternatives a code group can't express. | tab `title`, `icon`, `image` |
| `ngmd-image`, `ngmd-video` | Screenshots, YouTube or Vimeo. | image `src`, `alt`, `caption`, `width`. video `src`, `title` |
@@ -114,6 +114,7 @@ Rules:
## 6. Code samples
- Always set the language. Put the file path in a comment on the first line: `// src/app/app.config.ts`.
+- JSON samples get no path comment: JSON has no comments and the copy button copies the whole block. Name the file in the sentence above the sample instead.
- Highlight lines with `{3}` or `{2,5-7}` after the language. Count the path comment as line 1.
- Install commands use a code group: ` ```bash group="install" name="pnpm" active ` then npm, yarn, bun.
- `file="path#L5-L20"` imports a real file (relative to `apps/docs`, nothing outside it). It is not a title.
diff --git a/.gitattributes b/.gitattributes
index dbf6ce47..a84ef1a6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,3 +5,4 @@
*.zip binary
extension/ui/** linguist-generated=true
pnpm-lock.yaml linguist-generated=true
+extension/ui/THIRD_PARTY_LICENSES.md -text
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cfad349f..7776f178 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,8 +29,6 @@ jobs:
- uses: nrwl/nx-set-shas@v4
- - uses: nrwl/nx-set-shas@v4
-
- name: Check formatting
run: pnpm format:check
@@ -66,7 +64,7 @@ jobs:
node-22:
name: Package tests on Node.js 22
- # `engines.node` allows 22, while the build job runs the `.nvmrc` version.
+ # `engines.node` allows 22.12 and later, while the build job runs the `.nvmrc` version.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -96,3 +94,91 @@ jobs:
- name: Check the panel with axe
run: pnpm test:axe
+
+ # The e2e jobs below are slow, so they run only when the package, the panel, the SSR demo, the
+ # tools docs (e2e:mcp reads them) or the scripts and fixtures they use change. A diff that cannot
+ # be computed (a new branch, a force push) runs them.
+ changes:
+ name: Changed paths
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ e2e: ${{ steps.diff.outputs.e2e }}
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - id: diff
+ env:
+ BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
+ run: |
+ paths=(packages/devtools app e2e src apps/docs/src/content/agents angular.json package.json
+ scripts/e2e-security.mjs scripts/e2e-mcp.mjs pnpm-lock.yaml .github/workflows/ci.yml)
+ if git diff --quiet "$BASE" HEAD -- "${paths[@]}"; then
+ echo "e2e=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "e2e=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ e2e-security:
+ name: Security e2e
+ needs: changes
+ if: needs.changes.outputs.e2e == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+
+ # Playwright's own Chromium, as the axe job installs it.
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Build the package
+ run: pnpm devtools:build-pkg
+
+ # Leak corpus, access gates and a production build, on ports 5231 to 5236.
+ - name: Check redaction and access
+ run: pnpm e2e:security
+
+ - name: Keep the captured channels
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-security
+ path: dist/e2e-security
+ if-no-files-found: ignore
+
+ e2e-mcp:
+ name: MCP e2e
+ needs: changes
+ if: needs.changes.outputs.e2e == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ # Builds the package and the SSR demo itself, then serves the demo on port 5150 (the variant
+ # hubs on 5151 to 5154) and checks every tool, resource and prompt, and agents/tools.md.
+ - name: Check the agent surface
+ run: pnpm e2e:mcp --report dist/e2e-mcp/report.json
+
+ - name: Keep the report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-mcp
+ path: dist/e2e-mcp
+ if-no-files-found: ignore
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 00000000..ebcd2b73
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,169 @@
+name: E2E
+
+# The slow end-to-end checks that do not fit a pull request: every panel view in a real browser,
+# multi-tab and lifecycle resilience, and the install matrix from a packed tarball. Pull requests
+# run the security and MCP checks (ci.yml), and the release runs a slice of the install matrix on
+# the tarball it publishes (release.yml). See apps/docs/src/content/contributing/development.md.
+on:
+ schedule:
+ # Mondays at 07:00 UTC, an hour after latest.yml and compat.yml.
+ - cron: '0 7 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
+jobs:
+ panel:
+ name: Panel views
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ # The SSR demo serves the panel built into packages/devtools/dist/public.
+ - name: Build the package and the demo
+ run: |
+ pnpm devtools:build-pkg
+ pnpm build --configuration development
+
+ # The demo keeps the one-time code on unless PANGULAR_AUTH is false, and the panel check
+ # needs a connected panel.
+ - name: Start the SSR demo on port 4000
+ run: |
+ PANGULAR_AUTH=false PORT=4000 node dist/pangular-inspector/server/server.mjs > "$RUNNER_TEMP/demo.log" 2>&1 &
+ for _ in $(seq 60); do
+ curl -sf http://localhost:4000/ > /dev/null && exit 0
+ sleep 1
+ done
+ cat "$RUNNER_TEMP/demo.log"
+ exit 1
+
+ # Every tab and sub-view, dark and light, at 1280 and 360 px: axe, overflow and page errors.
+ - name: Check every panel view
+ run: pnpm e2e:panel --base http://localhost:4000 --out dist/e2e-panel
+
+ - name: Keep the screenshots and results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-panel
+ path: dist/e2e-panel
+ if-no-files-found: ignore
+
+ resilience:
+ name: Resilience
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Build the package and the demo
+ run: |
+ pnpm devtools:build-pkg
+ pnpm build --configuration development
+
+ # Starts the demo on port 5401 itself. Two checks fail today on open issues and are marked
+ # as known failures in the script, so they print XFAIL and do not fail the job:
+ # - expiry: a crashed tab stays listed for about 150 s (#422);
+ # - stress: the forms collector keeps 100 children per group and does not say so (#423).
+ # The soak check (`soak --minutes 30`) is manual only.
+ - name: Check multitab, expiry, lifecycle and stress
+ run: pnpm e2e:resilience multitab expiry lifecycle stress --json dist/e2e-resilience.json
+
+ - name: Keep the results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-resilience
+ path: dist/e2e-resilience.json
+ if-no-files-found: ignore
+
+ install:
+ name: Install ${{ matrix.name }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ strategy:
+ # Every cell reports, so one week's failures are all visible at once.
+ fail-fast: false
+ matrix:
+ include:
+ - name: angular-cli
+ args: --scenarios=angular-cli --pm=npm,pnpm --angular=20.3,21,22 --no-audit
+ - name: angular-cli-serve
+ args: --scenarios=angular-cli-serve --pm=npm,pnpm --angular=20.3,21,22 --no-audit
+ - name: angular-cli-nossr
+ args: --scenarios=angular-cli-nossr --pm=npm,pnpm --angular=20.3,21,22 --no-audit
+ - name: analog
+ args: --scenarios=analog --pm=pnpm --no-audit
+ - name: cli
+ args: --scenarios=cli --pm=npm,pnpm --no-audit
+ - name: package-audit
+ args: --audit-only
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ # Packs the package, publishes it to a throwaway local registry (port 4873, the apps on the
+ # ports after it) and installs it into fresh apps. A failed run keeps only the logs.
+ - name: Run the cells
+ run: >-
+ pnpm e2e:install ${{ matrix.args }}
+ --report=dist/e2e-install/report.json --logs=dist/e2e-install/logs
+
+ - name: Keep the report and the logs
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-install-${{ matrix.name }}
+ path: dist/e2e-install
+ if-no-files-found: ignore
+
+ report:
+ name: Report the failure
+ needs: [panel, resilience, install]
+ if: failure() && github.event_name == 'schedule'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ # One open issue for the weekly run, found by its title.
+ issues: write
+ steps:
+ - env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: |
+ title="ci: the weekly e2e run fails"
+ body="A job of the weekly E2E workflow failed: $RUN. The run's artifacts hold the reports and logs."
+ number=$(gh issue list --state open --search "\"$title\" in:title" --json number,title \
+ | jq -r --arg title "$title" 'map(select(.title == $title)) | .[0].number // empty')
+ if [ -n "$number" ]; then
+ gh issue comment "$number" --body "Still failing: $RUN"
+ else
+ gh issue create --title "$title" --body "$body" --label bug --label 'area: ci'
+ fi
diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml
index 4ea98c1a..36dea9e4 100644
--- a/.github/workflows/latest.yml
+++ b/.github/workflows/latest.yml
@@ -1,10 +1,12 @@
name: Latest versions
-# The package declares `@angular/core >=20` and `vite >=5`, and this repository pins the versions
-# it tests on, so a new Angular, Analog or Vite release can break users between our releases
-# without CI noticing (#100). Each week this makes fresh apps the way users do, on whatever those
-# ranges resolve to that day, installs the package from a local registry, wires the documented
-# setup, builds it and checks the hub answers. See apps/docs/src/content/contributing/publishing.md.
+# The package declares `@angular/core >=12.2.0 <23.0.0` and `vite >=5`, and this repository pins
+# the versions it tests on, so a new Angular, Analog or Vite release can break users between our
+# releases without CI noticing (#100). Each week this makes fresh apps the way users do, on whatever
+# those ranges resolve to that day, installs the package from a local registry, wires the
+# documented setup, builds it and checks the hub answers (`pnpm verify:publish`). The release
+# runs the same check on the tarball it publishes. See
+# apps/docs/src/content/contributing/publishing.md#releasing.
on:
schedule:
# Mondays at 06:00 UTC.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..8ba5f35a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,150 @@
+name: Release
+
+# Publishes @pangular-inspector/devtools to npm from a version tag, with provenance, after checking
+# the exact tarball it publishes. Push a tag such as v1.0.0 on a commit of main whose CI passed, or
+# run it from the Actions tab with that tag picked as the ref. See
+# apps/docs/src/content/contributing/publishing.md#releasing.
+on:
+ push:
+ tags: ['v*']
+ # Run with "Use workflow from" set to the tag; a branch is refused.
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ # One release at a time, and never cancelled halfway through a publish.
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: Publish ${{ github.ref_name }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ # Protect this environment (required reviewers, tags only) in the repository settings. It holds
+ # the NPM_TOKEN secret.
+ environment: npm
+ permissions:
+ # gh release create
+ contents: write
+ # npm publish --provenance signs the attestation with the workflow's OIDC token.
+ id-token: write
+ # gh run list reads the CI runs of the tagged commit.
+ actions: read
+ env:
+ # Both triggers run on the tag itself: a tag push, or a manual run with the tag as the ref.
+ TAG: ${{ github.ref_name }}
+ steps:
+ - name: Check the run is for a tag
+ if: github.ref_type != 'tag'
+ run: |
+ echo "::error::Release runs only on a version tag, not on ${GITHUB_REF}. Pick the tag in \"Use workflow from\"."
+ exit 1
+
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ github.ref }}
+ # The whole history, so the tagged commit can be checked against main.
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Check the tag is on main
+ run: |
+ if ! git merge-base --is-ancestor HEAD origin/main; then
+ echo "::error::$TAG points at $(git rev-parse --short HEAD), which is not on main. Release from main only."
+ exit 1
+ fi
+
+ # The full CI (ci.yml) must have passed on main for this exact commit, so a commit whose CI
+ # failed, or is still running, is never published.
+ - name: Check CI passed on the tagged commit
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ sha=$(git rev-parse HEAD)
+ passed=$(gh run list --workflow ci.yml --commit "$sha" --branch main --event push \
+ --status success --json databaseId -q 'length > 0')
+ if [ "$passed" != "true" ]; then
+ echo "::error::CI has no successful run on main for $sha. Wait for it to pass, or fix main and tag again."
+ exit 1
+ fi
+
+ # The tag, the package version and the changelog section must agree. The section becomes the
+ # GitHub release notes.
+ - name: Check the version and the changelog
+ run: node scripts/release-notes.mjs --tag="$TAG" --out="$RUNNER_TEMP/release-notes.md"
+
+ - uses: ./.github/actions/setup
+ with:
+ registry-url: https://registry.npmjs.org
+
+ - name: Type-check
+ run: pnpm typecheck
+
+ - name: Test devtools package
+ run: pnpm test:devtools
+
+ - name: Test devtools panel
+ run: pnpm test:panel
+
+ # `prepack` builds the library and the panel. The root .pnpmfile.cjs drops the workspace-only
+ # fields from the packed package.json. Everything after this step uses this one file.
+ - name: Pack the tarball
+ id: pack
+ run: |
+ mkdir -p dist/release
+ pnpm --filter @pangular-inspector/devtools pack --pack-destination "$PWD/dist/release"
+ tarball=$(ls "$PWD"/dist/release/*.tgz)
+ echo "tarball=$tarball" >> "$GITHUB_OUTPUT"
+ echo "Packed $(basename "$tarball")" >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Install Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ # Both publish the tarball to a throwaway local registry, never to npm.
+ - name: Verify the tarball (Angular CLI)
+ run: pnpm verify:publish --scenario=angular-cli --tarball="${{ steps.pack.outputs.tarball }}"
+
+ - name: Verify the tarball (Analog)
+ run: pnpm verify:publish --scenario=analog --tarball="${{ steps.pack.outputs.tarball }}"
+
+ # A small slice of the weekly install matrix (e2e.yml), plus the package audit.
+ - name: Install matrix on the tarball
+ run: >-
+ pnpm e2e:install --tarball="${{ steps.pack.outputs.tarball }}"
+ --cells=angular-cli:npm:22,angular-cli:pnpm:20.3,angular-cli-nossr:npm:22,analog:pnpm,cli:npm
+ --report=dist/release/e2e-install.json --logs=dist/release/e2e-install-logs
+
+ - name: Keep the tarball and the reports
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-${{ env.TAG }}
+ path: dist/release
+ if-no-files-found: ignore
+
+ # A version with a prerelease part (1.1.0-rc.1) goes to the `next` dist-tag, so `latest`
+ # only ever moves to a stable release.
+ - name: Publish to npm
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ TARBALL: ${{ steps.pack.outputs.tarball }}
+ run: |
+ case "$TAG" in
+ *-*) dist_tag=next ;;
+ *) dist_tag=latest ;;
+ esac
+ npm publish "$TARBALL" --provenance --access public --tag "$dist_tag"
+
+ - name: Create the GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TARBALL: ${{ steps.pack.outputs.tarball }}
+ run: |
+ prerelease=()
+ case "$TAG" in *-*) prerelease=(--prerelease) ;; esac
+ gh release create "$TAG" "$TARBALL" --verify-tag --title "$TAG" \
+ --notes-file "$RUNNER_TEMP/release-notes.md" "${prerelease[@]}"
diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs
new file mode 100644
index 00000000..940f9c1c
--- /dev/null
+++ b/.pnpmfile.cjs
@@ -0,0 +1,14 @@
+// pnpm hooks. Only packing is changed: nothing here affects how dependencies resolve.
+
+/**
+ * Drops the fields that only matter inside this workspace from the package.json that ships in a
+ * tarball (`pnpm pack`, `pnpm publish`): dev dependencies, the Nx target settings and the build
+ * scripts. The published manifest then holds only what an installer and a bundler read.
+ */
+function beforePacking(pkg) {
+ if (pkg.name !== '@pangular-inspector/devtools') return pkg;
+ const { devDependencies: _dev, nx: _nx, scripts: _scripts, ...published } = pkg;
+ return published;
+}
+
+module.exports = { hooks: { beforePacking } };
diff --git a/AGENTS.md b/AGENTS.md
index 54f0b278..3347808b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,7 +61,7 @@ Roles for delegating work live in `.claude/agents/`: `ui-engineer`, `inspector-e
## Serving Locally
-- **Demo app (SSR):** `pnpm build --configuration development && node dist/pangular-inspector/server/server.mjs` → http://localhost:4000
+- **Demo app (SSR):** `pnpm build --configuration development && node dist/pangular-inspector/server/server.mjs` → http://localhost:4000 (the panel asks for the one-time code the server prints; `PANGULAR_AUTH=false` turns it off for a local check. A production build serves no devtools)
- **Devtools SPA (hot reload):** `pnpm devtools:dev` → http://localhost:5173 (serves its own RPC, so source-scan data works; live tabs need an app page connected, so use the SSR server on 4000 for those)
- **Demo app (SPA, no SSR):** `pnpm start` → http://localhost:4200 (runs `ng serve` with SSR and hot reload; devtools popup + RPC work without a separate server)
- The devtools popup appears on the demo app page; click it to open the inspector panel
diff --git a/SECURITY.md b/SECURITY.md
index ce21e06f..904ab05f 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,8 +2,14 @@
## Supported versions
-Pangular Inspector is in early development. Only the latest published release of
-`@pangular-inspector/devtools` is supported. There are no older release lines to backport a fix to.
+Security fixes go to the 1.x release line of `@pangular-inspector/devtools`, in its latest
+minor release. Versions before 1.0 are not supported: upgrade to the latest 1.x release to get a
+fix.
+
+| Version | Supported |
+| ------- | --------- |
+| 1.x | Yes |
+| < 1.0 | No |
## Reporting a vulnerability
diff --git a/app/src/__tests__/app-shell.test.ts b/app/src/__tests__/app-shell.test.ts
index ea0dee1c..171ecc21 100644
--- a/app/src/__tests__/app-shell.test.ts
+++ b/app/src/__tests__/app-shell.test.ts
@@ -244,6 +244,14 @@ describe('App before the server trusts it', () => {
const input = form.querySelector('input')!;
expect(document.activeElement).toBe(input);
+ input.value = '123';
+ input.dispatchEvent(new Event('input'));
+ form.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
+ await settle(fixture);
+ expect(form.querySelector('[role="alert"]')?.textContent).toContain(
+ 'Enter the 6-digit code from the terminal.',
+ );
+
input.value = '000000';
input.dispatchEvent(new Event('input'));
form.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
@@ -258,6 +266,11 @@ describe('App before the server trusts it', () => {
expect(host(fixture).querySelector('app-auth-code')).toBeNull();
expect(status(fixture)).toBe('Live');
expect(host(fixture).querySelector('app-dashboard')).not.toBeNull();
+ expect(document.activeElement).toBe(host(fixture).querySelector('nav button.active'));
+ const statuses = [...host(fixture).querySelectorAll('main [role="status"]')].map((el) =>
+ el.textContent?.trim(),
+ );
+ expect(statuses).toContain('Connected.');
fixture.destroy();
});
});
diff --git a/app/src/__tests__/cd-diagnosis.test.ts b/app/src/__tests__/cd-diagnosis.test.ts
index ae7358ea..778668f0 100644
--- a/app/src/__tests__/cd-diagnosis.test.ts
+++ b/app/src/__tests__/cd-diagnosis.test.ts
@@ -1,5 +1,5 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
-import type { CdDiagnosis as Diagnosis } from '@pangular-inspector/devtools/config';
+import type { CdDiagnosis as Diagnosis } from '@pangular-inspector/devtools/internal/config';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CdDiagnosis } from '../pages/cd-diagnosis';
import { CdRecording, type CdPage } from '../pages/cd-recording';
diff --git a/app/src/__tests__/component-tree-expand.test.ts b/app/src/__tests__/component-tree-expand.test.ts
index 1eb4d9cc..e9d0cbc8 100644
--- a/app/src/__tests__/component-tree-expand.test.ts
+++ b/app/src/__tests__/component-tree-expand.test.ts
@@ -197,7 +197,7 @@ describe('ComponentTree Clear filter focus', () => {
const fixture = await setup();
fixture.componentInstance.setFilter('zzz');
await settle(fixture);
- const clear = button(fixture, 'Clear filter');
+ const clear = button(fixture, 'Clear filters');
clear.focus();
clear.click();
await settle(fixture);
@@ -236,7 +236,7 @@ describe('ComponentTree Clear filter focus', () => {
await settle(fixture);
fixture.componentInstance.filter.set('zzz');
await settle(fixture);
- const clear = button(fixture, 'Clear filter');
+ const clear = button(fixture, 'Clear filters');
clear.focus();
clear.click();
await settle(fixture);
diff --git a/app/src/__tests__/component-value-tree.test.ts b/app/src/__tests__/component-value-tree.test.ts
index 678821d5..49891b18 100644
--- a/app/src/__tests__/component-value-tree.test.ts
+++ b/app/src/__tests__/component-value-tree.test.ts
@@ -409,7 +409,7 @@ describe('ComponentTree value expansion', () => {
type(filter, 'zzz');
await settle(fixture);
expect(el(fixture).textContent).toContain('No inputs, outputs or properties match “zzz”');
- button(fixture, 'Clear filter').click();
+ button(fixture, 'Clear filters').click();
await settle(fixture);
expect(filter.value).toBe('');
expect(document.activeElement).toBe(filter);
diff --git a/app/src/__tests__/dashboard-cards.test.ts b/app/src/__tests__/dashboard-cards.test.ts
index 73982138..2df0a11c 100644
--- a/app/src/__tests__/dashboard-cards.test.ts
+++ b/app/src/__tests__/dashboard-cards.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { resolvePangularConfig } from '@pangular-inspector/devtools/config';
+import { resolvePangularConfig } from '@pangular-inspector/devtools/internal/config';
import {
componentsCard,
dashboardStats,
diff --git a/app/src/__tests__/di-inspector-pages.test.ts b/app/src/__tests__/di-inspector-pages.test.ts
index 46db795f..308dd7b8 100644
--- a/app/src/__tests__/di-inspector-pages.test.ts
+++ b/app/src/__tests__/di-inspector-pages.test.ts
@@ -310,7 +310,7 @@ describe('injector search box focus', () => {
button.click();
}
- it('keeps focus on the search box after Clear search in the live tree', async () => {
+ it('keeps focus on the search box after Clear filters in the live tree', async () => {
const fixture = await render({
roots: [
{
@@ -325,7 +325,7 @@ describe('injector search box focus', () => {
document.body.append(host);
fixture.componentInstance.query.set('zzz');
await fixture.whenStable();
- clickNamed(host, 'Clear search');
+ clickNamed(host, 'Clear filters');
await fixture.whenStable();
expect(fixture.componentInstance.query()).toBe('');
expect(document.activeElement).toBe(
@@ -348,7 +348,7 @@ describe('injector search box focus', () => {
]);
fixture.componentInstance.query.set('zzz');
await fixture.whenStable();
- clickNamed(host, 'Clear filter');
+ clickNamed(host, 'Clear filters');
await fixture.whenStable();
expect(host.textContent).toContain('CartService');
expect(document.activeElement).toBe(
diff --git a/app/src/__tests__/errors-view.test.ts b/app/src/__tests__/errors-view.test.ts
index 40af8e51..d4ad578b 100644
--- a/app/src/__tests__/errors-view.test.ts
+++ b/app/src/__tests__/errors-view.test.ts
@@ -168,7 +168,7 @@ describe('the Errors tab', () => {
search.dispatchEvent(new Event('input'));
await settle(fixture);
expect(text(fixture)).toContain('No error matches “zzz”.');
- button(fixture, 'Clear filter').click();
+ button(fixture, 'Clear filters').click();
await settle(fixture);
expect(host(fixture).querySelectorAll('ol.errors > li')).toHaveLength(1);
expect(document.activeElement).toBe(search);
diff --git a/app/src/__tests__/filter-status.test.ts b/app/src/__tests__/filter-status.test.ts
new file mode 100644
index 00000000..f4785e34
--- /dev/null
+++ b/app/src/__tests__/filter-status.test.ts
@@ -0,0 +1,45 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { filterAnnouncement } from '../ui/filter-status';
+
+describe('filterAnnouncement', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ function setup() {
+ const query = signal('');
+ const total = signal(10);
+ const text = () => `${total()} of ${total()} events`;
+ const status = TestBed.runInInjectionContext(() => filterAnnouncement(() => query(), text));
+ TestBed.tick();
+ return { query, total, status };
+ }
+
+ it('stays quiet when only the data changes', () => {
+ const { total, status } = setup();
+ total.set(11);
+ TestBed.tick();
+ vi.advanceTimersByTime(2000);
+ total.set(12);
+ TestBed.tick();
+ vi.advanceTimersByTime(2000);
+ expect(status()).toBe('');
+ });
+
+ it('speaks the count once after the user stops typing', () => {
+ const { query, total, status } = setup();
+ for (const value of ['t', 'tr', 'tri']) {
+ query.set(value);
+ TestBed.tick();
+ vi.advanceTimersByTime(100);
+ }
+ expect(status()).toBe('');
+ vi.advanceTimersByTime(1000);
+ expect(status()).toBe('10 of 10 events');
+ total.set(20);
+ TestBed.tick();
+ vi.advanceTimersByTime(2000);
+ expect(status()).toBe('10 of 10 events');
+ });
+});
diff --git a/app/src/__tests__/format.test.ts b/app/src/__tests__/format.test.ts
index c2f6635c..1dc001ef 100644
--- a/app/src/__tests__/format.test.ts
+++ b/app/src/__tests__/format.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { prettyJson, time } from '../format';
+import { plural, prettyJson, time } from '../format';
describe('time', () => {
it('formats a timestamp as the local time of day', () => {
@@ -28,3 +28,11 @@ describe('prettyJson', () => {
expect(prettyJson('"text"')).toBeNull();
});
});
+
+describe('plural', () => {
+ it('uses the singular for one and the plural otherwise', () => {
+ expect(plural(1, 'route')).toBe('1 route');
+ expect(plural(0, 'route')).toBe('0 routes');
+ expect(plural(2, 'navigation is', 'navigations are')).toBe('2 navigations are');
+ });
+});
diff --git a/app/src/__tests__/network-ssr-requests.test.ts b/app/src/__tests__/network-ssr-requests.test.ts
index 9391e18c..3d53386b 100644
--- a/app/src/__tests__/network-ssr-requests.test.ts
+++ b/app/src/__tests__/network-ssr-requests.test.ts
@@ -100,6 +100,14 @@ describe('NetworkInspector SSR requests', () => {
);
expect(host.querySelector('tr.selected .tag.server')!.textContent).toBe('this page');
+ // The requests are one Tab stop, named by method, path, status and render mode.
+ const rows = [...host.querySelectorAll('button.ssr-row')];
+ expect(rows.length).toBeGreaterThan(0);
+ expect(rows.filter((row) => row.tabIndex === 0)).toHaveLength(1);
+ expect(rows[0].textContent!.replace(/\s+/g, ' ').trim()).toMatch(
+ /^GET \/examples\/ssr , 200, \S/,
+ );
+
inspector.selectedRequestId.set('r2');
inspector.pages.update((pages) => pages.map((p) => ({ ...p, reportedAt: 2000 })));
await fixture.whenStable();
diff --git a/app/src/__tests__/plugins-view.test.ts b/app/src/__tests__/plugins-view.test.ts
index 320b2273..40dba76b 100644
--- a/app/src/__tests__/plugins-view.test.ts
+++ b/app/src/__tests__/plugins-view.test.ts
@@ -198,7 +198,7 @@ describe('the Plugins tab', () => {
search.dispatchEvent(new Event('input'));
await settle(fixture);
expect(text(fixture)).toContain('No row or item matches “zzz”.');
- button(fixture, 'Clear filter').click();
+ button(fixture, 'Clear filters').click();
await settle(fixture);
expect(host(fixture).querySelectorAll('tbody tr')).toHaveLength(2);
});
diff --git a/app/src/__tests__/query-inspector.test.ts b/app/src/__tests__/query-inspector.test.ts
index ea055d19..da5425db 100644
--- a/app/src/__tests__/query-inspector.test.ts
+++ b/app/src/__tests__/query-inspector.test.ts
@@ -154,7 +154,8 @@ describe('QueryInspector', () => {
} as unknown as DevframeRpcClient);
await settle(fixture);
const text = root(fixture).textContent!.replace(/\s+/g, ' ');
- expect(text).toContain('The connected page has no QueryClient.');
+ expect(text).toContain('The connected page has no QueryClient yet.');
+ expect(text).not.toContain('No page reports a QueryClient');
expect(text).toContain('provideTanStackQuery(new QueryClient())');
expect(text).not.toContain('Open the app with the overlay loaded');
});
diff --git a/app/src/__tests__/router-panels.test.ts b/app/src/__tests__/router-panels.test.ts
index 92b93f64..7db1c0ae 100644
--- a/app/src/__tests__/router-panels.test.ts
+++ b/app/src/__tests__/router-panels.test.ts
@@ -289,7 +289,7 @@ describe('RouteTree filter count', () => {
it('shows "N of M routes" while a search filters the tree and says it once', async () => {
const fixture = mount(RouteTree, page({ config: routes }), offline);
await settle(fixture);
- expect(summary(fixture)).toContain('3 route(s).');
+ expect(summary(fixture)).toContain('3 routes.');
expect(el(fixture).querySelector('.summary .count')).toBeNull();
type(fixture, 'users');
@@ -336,11 +336,11 @@ describe('RouteTree filter count', () => {
await settle(fixture);
type(fixture, 'zzz');
await settle(fixture);
- button(fixture, 'Clear filter').click();
+ button(fixture, 'Clear filters').click();
await settle(fixture);
expect(filterInput(fixture).value).toBe('');
expect(document.activeElement).toBe(filterInput(fixture));
- expect(summary(fixture)).toContain('3 route(s).');
+ expect(summary(fixture)).toContain('3 routes.');
expect(filterStatus(fixture)).toBe('');
});
});
diff --git a/app/src/__tests__/shell-deep-link.test.ts b/app/src/__tests__/shell-deep-link.test.ts
index 3fca0458..47758340 100644
--- a/app/src/__tests__/shell-deep-link.test.ts
+++ b/app/src/__tests__/shell-deep-link.test.ts
@@ -1,7 +1,7 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { parsePanelLink } from '@pangular-inspector/devtools/config';
+import { parsePanelLink } from '@pangular-inspector/devtools/internal/config';
const server = vi.hoisted(() => ({
/** Events the panel registered, by name, to call as the server would broadcast. */
diff --git a/app/src/__tests__/signal-inspector.test.ts b/app/src/__tests__/signal-inspector.test.ts
index 44e3faf8..8e7313da 100644
--- a/app/src/__tests__/signal-inspector.test.ts
+++ b/app/src/__tests__/signal-inspector.test.ts
@@ -332,6 +332,18 @@ describe('SignalInspector', () => {
expect(host.textContent).toContain('user');
});
+ it('offers Clear filters when a filter is set on an empty graph', async () => {
+ const fixture = await render({ ...graph, nodes: [], edges: [], resources: [] });
+ const host = fixture.nativeElement as HTMLElement;
+ fixture.componentInstance.filter.set('zzz');
+ await fixture.whenStable();
+ expect(host.textContent).toContain('No signals in this graph.');
+ const clear = Array.from(host.querySelectorAll('button')).find(
+ (b) => b.textContent?.trim() === 'Clear filters',
+ );
+ expect(clear).toBeDefined();
+ });
+
describe('filter box focus and Escape', () => {
afterEach(() => {
document.body.innerHTML = '';
diff --git a/app/src/__tests__/timeline.test.ts b/app/src/__tests__/timeline.test.ts
index 8258d0e3..dc235ab7 100644
--- a/app/src/__tests__/timeline.test.ts
+++ b/app/src/__tests__/timeline.test.ts
@@ -376,6 +376,8 @@ describe('the Timeline tab', () => {
expect(titles[0]).toBe('Navigation to /admin');
expect(titles.at(-1)).toBe('GET /api/trips');
expect(text(fixture)).toContain('9 of 9 events');
+ // The visible count is not a live region, so pushes never announce it.
+ expect(host(fixture).querySelector('.count')!.hasAttribute('role')).toBe(false);
const chart = host(fixture).querySelector('[role="img"]')!;
expect(chart.getAttribute('aria-label')).toContain('Router 2');
expect(host(fixture).querySelectorAll('h2')[0].textContent).toBe('Time axis');
diff --git a/app/src/__tests__/tint-contrast.test.ts b/app/src/__tests__/tint-contrast.test.ts
index 9c8f9f7c..64dab59c 100644
--- a/app/src/__tests__/tint-contrast.test.ts
+++ b/app/src/__tests__/tint-contrast.test.ts
@@ -159,9 +159,72 @@ describe('router timeline phase colours', () => {
}
});
+// Control outlines and signal graph edges are the only boundary of what they
+// draw, so they need 3:1 on every surface (WCAG 1.4.11).
+describe('control outlines and graph edges', () => {
+ const base = theme('amber');
+ for (const mode of ['dark', 'light'] as const) {
+ for (const token of ['control-border', 'graph-edge']) {
+ it(`${mode}: --${token} keeps 3:1 on every surface`, () => {
+ const tokens = base[mode];
+ expect(tokens[token], `--${token}`).toBeDefined();
+ for (const surface of ['bg', 'surface', 'surface-2', 'surface-3']) {
+ expect(
+ ratio(rgb(tokens[token]), rgb(tokens[surface])),
+ `on --${surface}`,
+ ).toBeGreaterThanOrEqual(3);
+ }
+ });
+ }
+ }
+});
+
+// Pressed and selected controls draw their border in --accent-line, a
+// percentage of the accent over the surface, so it needs 3:1 too.
+describe('accent borders', () => {
+ for (const accent of ACCENTS) {
+ const { css } = compileString(`@use 'theme' with ($accent: ${accent});`, {
+ loadPaths: [STYLES],
+ });
+ const themes = theme(accent);
+ const alphas = [...css.matchAll(/--accent-line-amount:\s*(\d+)%/g)];
+ for (const [index, mode] of (['dark', 'light'] as const).entries()) {
+ it(`${accent} ${mode}: --accent-line keeps 3:1 on every surface`, () => {
+ const amount = Number(alphas[index]?.[1]) / 100;
+ expect(amount).toBeGreaterThan(0);
+ const tokens = themes[mode];
+ for (const surface of ['bg', 'surface', 'surface-2', 'surface-3']) {
+ const under = rgb(tokens[surface]);
+ const line = mix(rgb(tokens['accent']), amount, under);
+ expect(ratio(line, under), `on --${surface}`).toBeGreaterThanOrEqual(3);
+ }
+ });
+ }
+ }
+});
+
+// Signal kind badges put --kind-ink on a solid --kind- fill.
+describe('signal kind badges', () => {
+ const tokens = theme('amber').dark;
+ const kinds = Object.keys(tokens).filter(
+ (name) => name.startsWith('kind-') && name !== 'kind-ink',
+ );
+ it('has a fill for every kind', () => expect(kinds.length).toBeGreaterThan(10));
+ for (const kind of kinds) {
+ it(`--kind-ink keeps 4.5:1 on --${kind}`, () => {
+ expect(ratio(rgb(tokens['kind-ink']), rgb(tokens[kind]))).toBeGreaterThanOrEqual(4.5);
+ });
+ }
+});
+
// Pages that used hard-coded hex colors now take them from the tokens above, so
// both themes get a value these tests check.
-const TOKEN_ONLY_PAGES = ['analog-inspector.ts', 'route-timeline.ts'];
+const TOKEN_ONLY_PAGES = [
+ 'analog-inspector.ts',
+ 'route-timeline.ts',
+ 'signal-graph-view.ts',
+ 'signal-inspector.ts',
+];
describe('pages that use theme tokens only', () => {
for (const file of TOKEN_ONLY_PAGES) {
diff --git a/app/src/angular-pages.ts b/app/src/angular-pages.ts
index b58955d8..b7ded26f 100644
--- a/app/src/angular-pages.ts
+++ b/app/src/angular-pages.ts
@@ -1,6 +1,6 @@
import { DestroyRef, effect, inject, signal, untracked, type Signal } from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
-import type { AngularPageInfo } from '@pangular-inspector/devtools/config';
+import type { AngularPageInfo } from '@pangular-inspector/devtools/internal/config';
export interface AngularPage extends AngularPageInfo {
pageId: string;
diff --git a/app/src/app.ts b/app/src/app.ts
index 3a64439f..01fcf99b 100644
--- a/app/src/app.ts
+++ b/app/src/app.ts
@@ -64,7 +64,7 @@ import {
parsePanelLink,
type HubView,
type PanelLink,
-} from '@pangular-inspector/devtools/config';
+} from '@pangular-inspector/devtools/internal/config';
import { Settings } from './pages/settings';
import { CommandPalette } from './ui/command-palette';
import type { PaletteCommand } from './palette-filter';
@@ -480,6 +480,7 @@ function readView(): View | null {
{{ backgroundNote() }}
+ {{ connectedNote() }}
@if (tab() === 'settings') {
(null);
+ /** Says "Connected." once the one-time code is accepted. */
+ readonly connectedNote = signal('');
private readonly hiddenPages = signal([]);
private readonly pageId = hostPageId();
readonly backgroundNote = computed(() => {
const hidden = this.hiddenPages();
if (this.pageId) {
- return hidden.includes(this.pageId) ? 'Tab in background, showing the last data.' : '';
+ return hidden.includes(this.pageId) ? 'Page in background, showing the last data.' : '';
}
if (!hidden.length) return '';
return hidden.length === 1
- ? 'A tab is in the background, showing its last data.'
- : `${hidden.length} tabs are in the background, showing their last data.`;
+ ? 'A page is in the background, showing its last data.'
+ : `${hidden.length} pages are in the background, showing their last data.`;
});
private stopVisibility = () => {};
private stopNative = () => {};
@@ -1324,11 +1327,19 @@ export class App implements OnInit, OnDestroy {
}
if (this.pendingClient === client && status !== 'connecting') this.pendingClient = null;
if (status === 'connected') {
+ const paired = this.pairing() === client;
this.pairing.set(null);
// devframe says it is trusted before it stores the token it got for the code.
setTimeout(() => auth.remember(client.connection?.authToken));
this.ready(client);
this.connected.set(this.rpc() === client);
+ // The code prompt that held focus is gone: say so and move to the tab bar.
+ if (paired) {
+ this.connectedNote.update((note) =>
+ note === 'Connected.' ? 'Connected.\u00a0' : 'Connected.',
+ );
+ this.focusCurrentTab();
+ }
} else if (status === 'unauthorized') {
this.connected.set(false);
if (this.rpc() === client) this.rpc.set(null);
diff --git a/app/src/devtools-config.ts b/app/src/devtools-config.ts
index 56041ae7..31a47f1d 100644
--- a/app/src/devtools-config.ts
+++ b/app/src/devtools-config.ts
@@ -5,7 +5,7 @@ import {
type PangularAction,
type PangularInspector,
type ResolvedPangularConfig,
-} from '@pangular-inspector/devtools/config';
+} from '@pangular-inspector/devtools/internal/config';
import type { Tab } from './types/tab.types';
export { actionBlockedMessage };
diff --git a/app/src/format.ts b/app/src/format.ts
index 322adc3a..1f2a0a9a 100644
--- a/app/src/format.ts
+++ b/app/src/format.ts
@@ -52,3 +52,8 @@ export function prettyJson(text: string): string | null {
}
return out;
}
+
+/** `1 route`, `3 routes`: the count and the noun in the matching number. */
+export function plural(count: number, one: string, many = `${one}s`): string {
+ return `${count} ${count === 1 ? one : many}`;
+}
diff --git a/app/src/hub-dock-sync.ts b/app/src/hub-dock-sync.ts
index 280fb3f0..9473d42a 100644
--- a/app/src/hub-dock-sync.ts
+++ b/app/src/hub-dock-sync.ts
@@ -1,4 +1,4 @@
-import { hubDockId } from '@pangular-inspector/devtools/config';
+import { hubDockId } from '@pangular-inspector/devtools/internal/config';
const CLIENT_CONTEXT_KEY = '__DEVFRAME_HUB_CLIENT_CONTEXT__';
diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts
index 358b6457..456ea53f 100644
--- a/app/src/pages/analog-inspector.ts
+++ b/app/src/pages/analog-inspector.ts
@@ -10,7 +10,7 @@ import {
untracked,
} from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
-import { time } from '../format';
+import { plural, time } from '../format';
import { hostPageId } from '../page-id';
import { rpcCall, rpcTry as call } from '../rpc';
import { actionAllowed, actionBlockedMessage } from '../devtools-config';
@@ -1029,7 +1029,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
+ {{ plural(p.built.length, 'page') }} in dist/analog/public
@if (p.notBuilt.length) {
· missing
@for (r of p.notBuilt; track r) {
@@ -1102,8 +1102,9 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
@case ('lint') {
@if (findings().length) {
- {{ findings().length }} issue(s) in {{ lintCards().length }} group(s). Each card
- says what is wrong, where, and how to fix it.
+ {{ plural(findings().length, 'issue') }} in
+ {{ plural(lintCards().length, 'group') }}. Each card says what is wrong, where, and
+ how to fix it.
@for (card of lintCards(); track card.rule) {
@@ -1432,7 +1433,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
box-sizing: border-box;
height: 34px;
padding: 0 12px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background-color: var(--bg);
color: var(--text);
@@ -1475,7 +1476,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
box-sizing: border-box;
height: 34px;
padding: 0 14px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
@@ -1490,7 +1491,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
}
.btn:hover {
background: var(--surface-3);
- border-color: var(--border-strong);
+ border-color: var(--control-border);
}
.btn:active {
transform: translateY(1px);
@@ -2152,6 +2153,7 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth
`,
})
export class AnalogInspector {
+ protected readonly plural = plural;
rpc = input(null);
readonly canCall = computed(() => actionAllowed(this.rpc(), 'analog'));
protected readonly callsOff = actionBlockedMessage('analog');
diff --git a/app/src/pages/audit-inspector.ts b/app/src/pages/audit-inspector.ts
index dd92566c..8bcd3867 100644
--- a/app/src/pages/audit-inspector.ts
+++ b/app/src/pages/audit-inspector.ts
@@ -10,6 +10,7 @@ import {
signal,
untracked,
} from '@angular/core';
+import { filterAnnouncement } from '../ui/filter-status';
import type { DevframeRpcClient } from 'devframe/client';
import { time } from '../format';
import { hostPageId } from '../page-id';
@@ -111,7 +112,8 @@ export const AUDIT_REFRESH_DELAY_MS = 150;
(valueChange)="pickPage($event)"
/>
}
- {{ countText() }}
+ {{ countText() }}
+ {{ countStatus() }}
{{ axeRunning() ? 'Running axe…' : 'Run axe' }}
@@ -311,7 +313,7 @@ export const AUDIT_REFRESH_DELAY_MS = 150;
height: var(--control-h);
padding: 0 12px 0 34px;
background: var(--bg);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
@@ -331,7 +333,7 @@ export const AUDIT_REFRESH_DELAY_MS = 150;
.small {
height: var(--control-h);
padding: 0 12px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
@@ -601,8 +603,14 @@ export class AuditInspector {
`${finding.subject ?? ''} ${finding.rule} ${finding.why}`.toLowerCase().includes(text)),
);
});
- readonly countText = computed(
- () => `${this.shown().length} of ${this.findings().length} findings`,
+ readonly countText = computed(() => {
+ const total = this.findings().length;
+ return `${this.shown().length} of ${total} ${total === 1 ? 'finding' : 'findings'}`;
+ });
+ /** Speaks the count only after the user changes the search, area, severity or page. */
+ readonly countStatus = filterAnnouncement(
+ () => [this.filter(), this.area(), this.severity(), this.pageId()],
+ () => this.countText(),
);
readonly numbers = computed(() => {
const metrics = this.report()?.metrics ?? {};
diff --git a/app/src/pages/browser-tools-setting.ts b/app/src/pages/browser-tools-setting.ts
index 04d1bd38..2b7b2555 100644
--- a/app/src/pages/browser-tools-setting.ts
+++ b/app/src/pages/browser-tools-setting.ts
@@ -137,7 +137,7 @@ type Status = 'connecting' | 'loading' | 'ready' | 'unsupported' | 'failed';
width: 36px;
height: 20px;
margin: 0;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: 999px;
background: var(--surface-3);
appearance: none;
@@ -196,7 +196,7 @@ type Status = 'connecting' | 'loading' | 'ready' | 'unsupported' | 'failed';
align-items: center;
height: var(--control-h);
padding: 0 12px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
diff --git a/app/src/pages/capacitor-call-detail.ts b/app/src/pages/capacitor-call-detail.ts
index ef54c218..84d77852 100644
--- a/app/src/pages/capacitor-call-detail.ts
+++ b/app/src/pages/capacitor-call-detail.ts
@@ -142,7 +142,7 @@ const TAB_ORDER: readonly DetailTab[] = ['args', 'outcome', 'timing'];
.small {
height: var(--control-h);
padding: 0 12px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
diff --git a/app/src/pages/capacitor-inspector.ts b/app/src/pages/capacitor-inspector.ts
index 20e2a880..6218ea55 100644
--- a/app/src/pages/capacitor-inspector.ts
+++ b/app/src/pages/capacitor-inspector.ts
@@ -582,7 +582,7 @@ const FILTER_LABELS: Record = {
height: var(--control-h);
padding: 0 12px 0 34px;
background: var(--bg);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
@@ -602,7 +602,7 @@ const FILTER_LABELS: Record = {
.small {
height: var(--control-h);
padding: 0 12px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
@@ -763,7 +763,7 @@ const FILTER_LABELS: Record = {
.copy {
height: 26px;
padding: 0 10px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
diff --git a/app/src/pages/cd-chart-data.ts b/app/src/pages/cd-chart-data.ts
index 71a92371..c2de548e 100644
--- a/app/src/pages/cd-chart-data.ts
+++ b/app/src/pages/cd-chart-data.ts
@@ -1,4 +1,8 @@
-import type { CdComponentStat, CdCycle, CdSpanKind } from '@pangular-inspector/devtools/config';
+import type {
+ CdComponentStat,
+ CdCycle,
+ CdSpanKind,
+} from '@pangular-inspector/devtools/internal/config';
/** One frame at 60 frames per second. A cycle longer than this can drop a frame. */
export const FRAME_BUDGET_MS = 16.6;
diff --git a/app/src/pages/cd-cycle-chart.ts b/app/src/pages/cd-cycle-chart.ts
index 368375b2..e0a17c15 100644
--- a/app/src/pages/cd-cycle-chart.ts
+++ b/app/src/pages/cd-cycle-chart.ts
@@ -1,5 +1,5 @@
import { Component, computed, input, model, signal } from '@angular/core';
-import type { CdCycle } from '@pangular-inspector/devtools/config';
+import type { CdCycle } from '@pangular-inspector/devtools/internal/config';
import {
FRAME_BUDGET_MS,
chartMax,
@@ -221,7 +221,7 @@ interface Bar {
height: var(--control-h);
padding: 0 8px;
background: var(--bg);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
@@ -234,7 +234,7 @@ interface Bar {
height: var(--control-h);
padding: 0 12px;
background: var(--surface-2);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
diff --git a/app/src/pages/cd-diagnosis.ts b/app/src/pages/cd-diagnosis.ts
index 9dbdc2aa..9f972739 100644
--- a/app/src/pages/cd-diagnosis.ts
+++ b/app/src/pages/cd-diagnosis.ts
@@ -1,6 +1,6 @@
import { Component, computed, input } from '@angular/core';
import { CD_CAUSE_KINDS, CD_CAUSE_LABEL, cdHints } from '@pangular-inspector/devtools/cd-data';
-import type { CdReport } from '@pangular-inspector/devtools/config';
+import type { CdReport } from '@pangular-inspector/devtools/internal/config';
const SHOWN = 10;
diff --git a/app/src/pages/cd-flame-graph.ts b/app/src/pages/cd-flame-graph.ts
index 21bcdd8a..16ad9079 100644
--- a/app/src/pages/cd-flame-graph.ts
+++ b/app/src/pages/cd-flame-graph.ts
@@ -1,5 +1,5 @@
import { Component, computed, input, output, signal } from '@angular/core';
-import type { CdCycle } from '@pangular-inspector/devtools/config';
+import type { CdCycle } from '@pangular-inspector/devtools/internal/config';
import {
FLAME_MAX_CYCLES,
SPAN_KIND,
diff --git a/app/src/pages/cd-recording.ts b/app/src/pages/cd-recording.ts
index 429862c0..551ab994 100644
--- a/app/src/pages/cd-recording.ts
+++ b/app/src/pages/cd-recording.ts
@@ -10,7 +10,7 @@ import {
signal,
untracked,
} from '@angular/core';
-import type { CdOptions, CdReport } from '@pangular-inspector/devtools/config';
+import type { CdOptions, CdReport } from '@pangular-inspector/devtools/internal/config';
import type { DevframeRpcClient } from 'devframe/client';
import { panelConfig } from '../devtools-config';
import { time } from '../format';
@@ -19,7 +19,7 @@ import { LimitNote } from '../ui/limit-note';
import { AngularUnavailable } from '../ui/angular-unavailable';
import { angularPageFor, injectAngularPages } from '../angular-pages';
import { cdCauseText } from '@pangular-inspector/devtools/cd-data';
-import type { CdCause } from '@pangular-inspector/devtools/config';
+import type { CdCause } from '@pangular-inspector/devtools/internal/config';
import { aggregateComponents, cyclesInRange, type CdRange } from './cd-chart-data';
import { CdDiagnosis } from './cd-diagnosis';
import { CdCycleChart } from './cd-cycle-chart';
@@ -28,7 +28,11 @@ import { CdTiming } from './cd-timing';
import { CdTransfer, type CdImported } from './cd-transfer';
import { CdTreemap } from './cd-treemap';
-export type { CdCheck, CdComponentStat, CdCycle } from '@pangular-inspector/devtools/config';
+export type {
+ CdCheck,
+ CdComponentStat,
+ CdCycle,
+} from '@pangular-inspector/devtools/internal/config';
/** How long Stop waits for the page's last report before it announces the count it has. */
const STOP_REPORT_WAIT_MS = 3000;
@@ -299,7 +303,7 @@ const SHOWN_CYCLES = 30;
height: var(--control-h);
padding: 0 12px;
background: var(--surface-2);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
diff --git a/app/src/pages/cd-timing.ts b/app/src/pages/cd-timing.ts
index 1f3c77f3..ec4a372d 100644
--- a/app/src/pages/cd-timing.ts
+++ b/app/src/pages/cd-timing.ts
@@ -1,5 +1,5 @@
import { Component, computed, input } from '@angular/core';
-import type { CdReport } from '@pangular-inspector/devtools/config';
+import type { CdReport } from '@pangular-inspector/devtools/internal/config';
const SHOWN = 10;
diff --git a/app/src/pages/cd-transfer.ts b/app/src/pages/cd-transfer.ts
index 8bc4cf4e..c6967d68 100644
--- a/app/src/pages/cd-transfer.ts
+++ b/app/src/pages/cd-transfer.ts
@@ -5,7 +5,7 @@ import {
cdTraceFile,
parseCdImport,
} from '@pangular-inspector/devtools/cd-data';
-import type { CdReport } from '@pangular-inspector/devtools/config';
+import type { CdReport } from '@pangular-inspector/devtools/internal/config';
/** A recording read from a file, checked like a page push. */
export interface CdImported {
@@ -59,7 +59,7 @@ function download(value: unknown, fileName: string) {
height: var(--control-h);
padding: 0 12px;
background: var(--surface-2);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
diff --git a/app/src/pages/cd-treemap.ts b/app/src/pages/cd-treemap.ts
index 232a305e..fc5da67e 100644
--- a/app/src/pages/cd-treemap.ts
+++ b/app/src/pages/cd-treemap.ts
@@ -1,5 +1,5 @@
import { Component, computed, input, output, signal } from '@angular/core';
-import type { CdComponentStat } from '@pangular-inspector/devtools/config';
+import type { CdComponentStat } from '@pangular-inspector/devtools/internal/config';
import { formatMs, squarify } from './cd-chart-data';
/** Components drawn as their own cell; the rest share one "other" cell. */
diff --git a/app/src/pages/coming-soon.ts b/app/src/pages/coming-soon.ts
index 77bac0b7..78a52173 100644
--- a/app/src/pages/coming-soon.ts
+++ b/app/src/pages/coming-soon.ts
@@ -260,7 +260,7 @@ export interface ComingSoonInfo {
max-width: 100%;
min-height: 44px;
padding: 8px 16px 8px 8px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
diff --git a/app/src/pages/component-prop-value.ts b/app/src/pages/component-prop-value.ts
index acb42515..914658e9 100644
--- a/app/src/pages/component-prop-value.ts
+++ b/app/src/pages/component-prop-value.ts
@@ -142,7 +142,7 @@ export function parseDraft(text: string): { value: unknown } | { error: string }
height: var(--control-h);
padding: 0 10px;
background: var(--bg);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font-mono);
@@ -161,7 +161,7 @@ export function parseDraft(text: string): { value: unknown } | { error: string }
height: var(--control-h);
padding: 0 12px;
background: var(--surface-2);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
cursor: pointer;
diff --git a/app/src/pages/component-tree-state.ts b/app/src/pages/component-tree-state.ts
index f17339a3..1c35a9fc 100644
--- a/app/src/pages/component-tree-state.ts
+++ b/app/src/pages/component-tree-state.ts
@@ -2,7 +2,7 @@ import {
isEmptyTreeQuery,
matchesTreeQuery,
parseTreeQuery,
-} from '@pangular-inspector/devtools/config';
+} from '@pangular-inspector/devtools/internal/config';
export type HydrationStatus = 'hydrated' | 'skipped' | 'mismatched' | 'client';
diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts
index 3fe2fcf1..754f09df 100644
--- a/app/src/pages/component-tree.ts
+++ b/app/src/pages/component-tree.ts
@@ -17,7 +17,10 @@ import {
import type { DevframeRpcClient } from 'devframe/client';
import { ExtensionBridge, type SourceLocation } from '../extension-bridge';
import { hostPageId } from '../page-id';
-import { angularVersionAtLeast, lacksCapability } from '@pangular-inspector/devtools/config';
+import {
+ angularVersionAtLeast,
+ lacksCapability,
+} from '@pangular-inspector/devtools/internal/config';
import { angularPageFor, injectAngularPages } from '../angular-pages';
import { AngularUnavailable } from '../ui/angular-unavailable';
import { pickPage } from '../live-pages';
@@ -321,7 +324,7 @@ function storedDirectiveHosts(): boolean {
@if (!showDirectiveHosts() && page()!.directiveHostCount) {
Directive hosts are hidden, so they are not searched.
}
- Clear filter
+ Clear filters
}
@if (rowWindow.enabled()) {
@@ -540,7 +543,7 @@ function storedDirectiveHosts(): boolean {
The filter searches names, and the nested keys of values you opened.
- Clear filter
+ Clear filters
}
@@ -905,7 +908,7 @@ function storedDirectiveHosts(): boolean {
} @else if (!filteredSource().length) {
No components match “{{ filter().trim() }}”
-
Clear filter
+
Clear filters
} @else {
@@ -638,7 +638,7 @@ function isTree(value: unknown): value is InjectorNode[] {
@@ -728,9 +728,10 @@ function isTree(value: unknown): value is InjectorNode[] {
.segmented {
display: inline-flex;
flex: none;
+ height: var(--control-h);
padding: 3px;
gap: 2px;
- border: 1px solid var(--border);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--bg);
}
@@ -738,7 +739,7 @@ function isTree(value: unknown): value is InjectorNode[] {
display: inline-flex;
align-items: center;
gap: 6px;
- height: 26px;
+ height: 100%;
padding: 0 10px;
border: 0;
border-radius: 6px;
@@ -806,7 +807,7 @@ function isTree(value: unknown): value is InjectorNode[] {
height: 34px;
padding: 0 12px 0 34px;
background: var(--bg);
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
@@ -880,7 +881,7 @@ function isTree(value: unknown): value is InjectorNode[] {
max-width: 100%;
height: 26px;
padding: 0 10px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: 99px;
background: var(--surface);
color: var(--text);
@@ -1226,7 +1227,7 @@ function isTree(value: unknown): value is InjectorNode[] {
align-items: center;
height: var(--control-h);
padding: 0 14px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
@@ -1353,7 +1354,7 @@ function isTree(value: unknown): value is InjectorNode[] {
.provider-group button {
height: 34px;
padding: 0 14px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
diff --git a/app/src/pages/di-provider-value.ts b/app/src/pages/di-provider-value.ts
index 28fbd9f8..fbab4a00 100644
--- a/app/src/pages/di-provider-value.ts
+++ b/app/src/pages/di-provider-value.ts
@@ -112,7 +112,7 @@ let nextId = 0;
gap: 4px;
min-height: 28px;
padding: 0 8px 0 4px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
diff --git a/app/src/pages/error-explain.ts b/app/src/pages/error-explain.ts
index 884df648..88093501 100644
--- a/app/src/pages/error-explain.ts
+++ b/app/src/pages/error-explain.ts
@@ -367,7 +367,7 @@ const STEP_TONE: Record
= {
justify-self: start;
height: var(--control-h);
padding: 0 12px;
- border: 1px solid var(--border-strong);
+ border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
diff --git a/app/src/pages/errors-inspector.ts b/app/src/pages/errors-inspector.ts
index 82c0f7e6..28563846 100644
--- a/app/src/pages/errors-inspector.ts
+++ b/app/src/pages/errors-inspector.ts
@@ -9,6 +9,7 @@ import {
output,
signal,
} from '@angular/core';
+import { filterAnnouncement } from '../ui/filter-status';
import type { DevframeRpcClient } from 'devframe/client';
import { time } from '../format';
import { hostPageId } from '../page-id';
@@ -116,7 +117,8 @@ const LEVEL_TONE: Record = {
(valueChange)="pageId.set($event ?? allPages)"
/>
}
- {{ countText() }}
+ {{ countText() }}
+ {{ countStatus() }}
@if (view() === 'errors') {
@@ -143,7 +145,7 @@ const LEVEL_TONE: Record