Architecture Overview¶
This repository is a disciplined monorepo with:
- src/backend/: Backend analysis engine + HTTP API (ASP.NET Core Minimal API)
- src/frontend/web/: Interactive forensic UI (React + TypeScript + Vite)
- src/shared/: (reserved for future shared DTOs/utilities; optional)
The backend is organized as a modular monolith:
- PostgresQueryAutopsyTool.Core
- Raw plan DTO shapes (Postgres JSON schema)
- Normalized plan node model
- Analysis pipeline:
- Parser (EXPLAIN (FORMAT JSON) → NormalizedPlanNode; BUFFERS counters from flat node keys and/or nested Buffers, plus conservative Workers merge onto the parent when a counter is missing there, while still storing each worker line as PlanWorkerStats on NormalizedPlanNode.Workers)
- Index posture (IndexSignalAnalyzer → PlanIndexOverview + bounded PlanIndexInsight[] on PlanAnalysisResult; feeds Analyze UI and complements findings P/Q/R/S without duplicating rule ranking)
- Derived metrics (DerivedMetricsEngine → per-node metrics + shares)
- Findings (FindingsEngine + rules → ranked evidence-based findings)
- Summary + narrative + Phase 60–61 PlanStory (PlanStoryBuilder after suggestions: execution overview, work concentration, expense drivers, inspect-first path, StoryPropagationBeat[] propagation lines each with optional focusNodeId + human anchorLabel, index/shape note with operator anchors when an index insight maps to a node). Phase 61 PlanNodeHumanReference + PlanNodeReferenceBuilder derive operator/relation/join-role/sort/aggregate/gather labels (hedged QueryCorrespondenceHint when source SQL is present); NodeLabelFormatter.ShortLabel delegates to PrimaryLabelCore so narrative/bottlenecks avoid raw root.* paths when evidence exists. Summary + narrative (PlanSummaryBuilder builds PlanSummary including Phase 58–59 Bottlenecks via internal PlanBottleneckBuilder + HumanAnchorLabel on each insight, BottleneckClassifier for BottleneckClass / BottleneckCauseHint, BottleneckPropagationHelper for PropagationNote; NarrativeGenerator uses SafePrimary for hotspot lists; OperatorNarrativeHelper + selected-node interpretation include optional query hints; PlanNodeInterpretationAugmentor runs after summary with query text)
- Optimization suggestions (OptimizationSuggestionEngine): consumes findings, PlanIndexOverview / PlanIndexInsight[], and per-node OperatorContextEvidence (+ worker lists on NormalizedPlanNode) to emit ranked, evidence-linked OptimizationSuggestion records (categories, action types, OptimizationSuggestionFamily, RecommendedNextAction, WhyItMatters, optional TargetDisplayLabel, IsGroupedCluster, cautions, validation steps). Copy is human-readable in titles/summaries; raw finding snippets remain in details/rationale. Overlapping statistics findings consolidate conservatively into one grouped card. Not a second findings engine and not DDL prescriptions.
- Compare-scoped suggestions (CompareOptimizationSuggestionEngine): runs after ComparisonEngine produces findings diff + IndexComparisonSummary, yielding compareOptimizationSuggestions oriented to “what to try next on plan B given the change,” using the same extended suggestion model and NodeLabelFormatter-style labels for plan B targets.
- Comparison engine (heuristic node mapping + deltas + pair details + findings diff)
- Report generators (Markdown/HTML/JSON) rendered from PlanAnalysisResult
- PostgresQueryAutopsyTool.Api
- HTTP endpoints
- Request validation
- SQLite artifact persistence (IArtifactPersistenceStore / SqliteArtifactStore): full JSON snapshots for PlanAnalysisResult and PlanComparisonResultV2 (opaque ids, optional TTL / max-row retention). Phase 49: writes stamp ArtifactSchemaVersion / persistence time; reads deserialize with tolerant JSON converters, then PersistedArtifactNormalizer upgrades legacy payloads and may attach alsoKnownAs on compare suggestions for deep-link stability; corrupt rows yield 422, unsupported future schema 409 (rows retained on corrupt reads). Phase 50: optional E2E:Enabled registers /api/e2e/seed/* helpers plus SqliteArtifactStore.UpsertRawJsonForE2E for deterministic browser tests only.
- Auth (Phase 37–38): AuthIdentityMiddleware resolves UserIdentity (stable UserId, group ids, AuthIdentitySource) via ProxyHeaders, legacy BearerSubject, JwtBearer (HS256 + sub), or ApiKey ( SqliteApiKeyPrincipalStore — hashed keys). IRequestIdentityAccessor is the supported read surface for endpoints; ArtifactAccessEvaluator enforces StoredArtifactAccess. Optional fixed-window rate limiting on analyze/compare POSTs.
- Swagger/OpenAPI
Phase 55 (UI): Dark-theme design tokens gain signal colors (info/warn/error/denial) and a subtle ambient root background; workstation-patterns.css adds state banners, summary deck, sharing chrome, and guided suggestion blocks. Top bar includes a short workstation tagline; prefers-reduced-motion trims animations.
Phase 56 (UI delivery + parity): Typography ships via bundled @fontsource/* (Outfit, IBM Plex Sans, JetBrains Mono) — no runtime Google Fonts dependency. artifactErrorPresentation + ArtifactErrorBanner unify Analyze and Compare persisted/request error tone/title semantics. html[data-visual-regression='1'] flattens #root background for Playwright pixels. Workspace customizer uses pqat-customizer--chrome to align with sharing panel framing.
Phase 57 (visual hardening + meta chrome): e2e-visual gains a fourth frame (403 access denied, page.route-mocked so CI stays on .env.testing only). pqat-metaPanel + pqat-authHelpCard tie customizer, sharing, and capture <details> surfaces into one utility family; ArtifactErrorBanner uses tone-specific body kickers (Policy / Notice / Error). Frontend npm test = vitest run; test:watch for local dev. CI frontend job calls npm test without duplicate flags.
Phase 62 (story refinement + narrative UI): Backend PlanNodeReferenceBuilder walks up to the nearest join for outer/inner and probe/build roles (Hash-on-left vs Hash-on-right heuristic), Hash build table (…) primary labels, and JoinSideRelations via RelationThroughHashWrapper. OperatorNarrativeHelper.SymptomNoteIfJoinHeavySide adds hash-build-side symptom notes. CompareOptimizationSuggestionEngine + ComparisonStoryBuilder tighten human anchor copy. Frontend storyPresentation (Plan briefing / Change briefing labels), planReferencePresentation.humanNodeAnchorFromPlan, workstation-patterns.css story lanes + bottleneck triage cards, TechnicalIdCollapsible, and guide/summary layout updates keep canonical ids secondary.
Phase 63 (compare briefing + operator readout density): OperatorNarrativeHelper.BuildOperatorBriefingLine + AnalyzedPlanNode.OperatorBriefingLine (set in PlanNodeInterpretationAugmentor) give a single dense label · role · pressure line aligned with PlanNodeHumanReference. PlanNodeReferenceBuilder extends JoinFamilyTypeLooksBinary / FormatJoinSideRole for semi/anti join variants (conservative existence/anti wording; hash-style semi/anti still use probe/build heuristics). ComparisonStoryBuilder consumes BottleneckComparisonBrief for a regression+unchanged-class beat, adds severe-findings delta beats, and rewrites several beats for engineer walkthrough tone; ComparisonEngine.BuildNarrative, FormatPairEvidence, and FindingIndexDiffLinker drop rule-id–first / backtick-heavy phrasing where practical. Frontend briefingReadoutPresentation, pqat-operatorBriefing, and pair briefing grid on CompareSelectedPairPanel.
Phase 64 (forensic briefing refinement): PlanBottleneckBriefingOverlay copies OperatorBriefingLine onto PlanBottleneckInsight after augment (see PlanAnalysisService). HashJoinProbeBuildIndices tightens probe/build when both direct children are Hash (smaller row magnitude → build) or when neither is direct Hash but a Hash appears under a join child within shallow depth without piercing nested join roots; ambiguous cases label probe/build unclear—defaulting to child order. ComparisonStoryBeat.BeatBriefing surfaces plan B’s briefing on the primary regression beat (JSON beatBriefing). OperatorNarrativeHelper deepens sort / gather / partial·final aggregate bottleneck copy. PlanStoryBuilder Inspect first steps are more tactical; fixtures gather_merge_partial_agg, hash_join_nested_build_hash back tests. Frontend pqat-bottleneckCard__briefing, pqat-changeStoryBeat__briefing.
Phase 65 (theme system + cross-theme polish): Frontend theme/ (THEME_STORAGE_KEY pqat_theme_v1, preference system | dark | light, default system) drives html[data-theme-preference] and effective html[data-theme="dark"|"light"] plus documentElement.style.colorScheme. useThemePreference subscribes to prefers-color-scheme when preference is system (no one-shot snapshot). An inline boot script in index.html applies the same key before first paint to limit FOUC. ThemeAppearanceSelect lives in the app top bar. Light and dark each have intentional token sets in index.css (not a single inversion); story lanes, banners, graph chrome, and chips use shared variables where practical. PersistedArtifactNormalizer (Phase 65): after loading persisted analyze/compare JSON, re-attaches PlanBottleneckBriefingOverlay.AttachOperatorBriefings when plan nodes still carry operatorBriefingLine so older snapshots regain bottleneck Briefing without re-analysis. Playwright e2e-visual locks localStorage + data-theme to dark alongside data-visual-regression. Vitest src/theme/theme.test.ts covers resolver + hook persistence/DOM attributes; useThemePreference guards missing matchMedia (jsdom).
Phase 66 (theme hardening + storytelling audit): data-effective-theme mirrors the resolved skin for tests. --pqat-tint-* / --pqat-story-edge-* tokens replace stray hex in workstation-patterns.css, CompareTopChangesPanel, and AnalyzePlanGraphCore. Light story lanes get clearer borders; --code-bg nudges cooler for mono readouts. App loads fetchAppConfig once; when authEnabled, useThemePreference({ serverSyncEnabled }) hydrates from GET /api/me/preferences/appearance_theme_v1 (JSON string value) and debounces PUT after changes — same pattern as workspace layout keys. themePresentation supplies labels + aria-describedby for System mode. e2e/theme-appearance.spec.ts (in e2e-smoke) asserts DOM theme state, reload persistence, and distinct top-bar computed colors. ComparisonEngine index/finding corroboration line copy tightened.
Phase 67 (nested-loop / rewrite continuity): PlanNodeReferenceBuilder.PairRegionContinuityHint + optional continuity suffix on PairHumanLabel feed NodePairDetail.RegionContinuityHint and compare story beats when MatchConfidence ≥ Medium. OperatorNarrativeHelper stresses nested-loop inner repetition (loops × inner access path). CompareOptimizationSuggestionEngine adds a rewrite operator-shape observation when continuity hints exist. Frontend surfaces regionContinuityHint on selected pair and What changed most. Unit fixtures nl_inner_seq_heavy, rewrite_nl_orders_lineitems, rewrite_hash_orders_lineitems exercise narrative + compare paths.
Phase 68 (scan / order rewrite continuity): PairRegionContinuityHint adds sort-under-seq → ordered index wording (parent Sort on A + token overlap between sort key and plan B index cond / presorted key), optional Sort → index scan continuity when mapped, and sharper seq → index copy (“broad scan” / residual cost). NodeMappingEngine.Score adds accessRewrite bonus for same-relation Seq Scan ↔ index-backed scan pairs so continuity is not suppressed as Low-confidence noise. ComparisonStoryBuilder uses continuity on top worsened as well as improved runtime beats; CompareOptimizationSuggestionEngine.RegionContinuityFollowUpSentence tailors the second sentence. Frontend compareContinuityPresentation.pairContinuitySectionTitle labels readouts. Fixtures rewrite_access_*_shipments, rewrite_sort_seq_shipments, rewrite_index_ordered_shipments back tests.
Phase 69 (richer rewrite continuity + summary cue): ClassifyOrderingContinuity prefers column-name alignment between parsed sort keys and index cond / presorted key (identifier-boundary match) for Strong ordering evidence; token overlap yields a Weak hedged line. Same-relation scan hints cover seq ↔ bitmap heap, bitmap ↔ index / index-only, index ↔ index-only, with residual-cost framing. CompareContinuitySummaryCue.FromHint maps long hints to short strings; NodePairDetail.RegionContinuitySummaryCue is serialized as regionContinuitySummaryCue. NodeMappingEngine.accessRewrite bonus extends to the scan-family pairs above. Compare Change briefing shows the cue chip when the selected pair (or first focused story beat) carries a cue. Fixtures rewrite_access_bitmap_shipments, rewrite_access_idxonly_shipments (+ .sql) augment tests.
Phase 70 (continuity confidence + structured cues): TryPairRegionContinuity returns RegionContinuityData (KindKey, ContinuityOutcome, full Hint). CompareContinuitySummaryCue.FromContinuity maps kind + outcome to compact chips (regression paths are not phrased as wins). ORDER BY text in QueryText acts as a conservative tie-breaker when planner JSON is opaque. Aggregate continuity covers partial vs finalize when GROUP BY keys align. NodePairDetail.continuityKindKey mirrors KindKey. Playwright smoke asserts the continuity chip in a real Compare POST.
Phase 71 (aggregate/output continuity + query-text grouping): TryPairRegionContinuity adds Gather / Gather Merge ↔ non-partial aggregate branches, GROUP BY substring bridging for mismatched Group Key text, and time_bucket-aware hedges when SQL and keys suggest bucketing. NodeMappingEngine classifies Gather as its own operator family (vs Aggregate) with near-family similarity and a gatherAggRewrite score bump so parallel stacks map to single-node aggregates for compare. ComparisonStoryBuilder appends a short grouped-output residual tail when continuity hints reference grouped-output / output-shaping / partial vs finalize / gather-merge. CompareOptimizationSuggestionEngine tail lines cover grouped-output and thin JSON + ORDER BY query assist. Frontend pairContinuitySectionTitle adds Grouped output · same region. E2E bundles rewrite_access_idx_shipments / rewrite_access_bitmap_shipments for a regression continuity smoke test.
Phase 72 (analyze corpus sweep + clipboard reliability): PostgresJsonAnalyzeFixtureSweepTests validates every fixtures/postgres-json/*.json (top-level) through PlanAnalysisService with structural checks on PlanStory, summaries, findings, and suggestions. cumulative_group_by.json is repaired to pure JSON and covered by a targeted grouped-output test. Frontend copyToClipboard, useCopyFeedback, ClickableRow click guards, and nodeReferences copy payloads support dependable Copy actions on http and https; Playwright (Phase 77) asserts Analyze → Copy reference via captured writeText payload (not readText() in page).
Phase 73 (CI validity + copy-path ergonomics): GitHub Actions .github/workflows/ci.yml quotes run: values where shell hints include # or plain Local: … (YAML # comments and : mapping syntax otherwise break parsing). e2e-playwright keeps Docker Compose playwright on e2e-smoke (includes persisted-flows clipboard regression). Frontend npm run test:e2e:copy runs that spec only; shareAppUrl.appUrlForPath centralizes origin + pathname for copy-link actions (Analyze summary, selected node, Compare deep link).
Phase 74 (fixture sweep ergonomics + workflow guard + Make paths): AnalyzeFixtureCorpus + AnalyzeFixtureStructuralAssertions in tests/.../Support/ centralize postgres-json discovery and structural post-analyze checks; AnalyzeFixtureCorpusTests guards the directory path. scripts/lint-workflows.sh + .github/workflows/workflow-lint.yml (actionlint, path-scoped on workflow changes) plus .actionlint.yaml. Makefile documents repo-health, verify, verify-docker, test-backend-docker, test-e2e-copy, and lint-workflows.
Phase 75 (visual regression stabilization): Playwright e2e-visual replaces brittle full-page Analyze/Compare baselines with element screenshots tied to aria-label story regions (Analysis summary, Analyze workspace, Findings list, Compare summary, Compare navigator, Compare pair inspector). Error paths snapshot analyze-page-error only. Helpers in e2e/visual/visualTestHelpers.ts.
Phase 76 (visual reproducibility + graph settle): @playwright/test is pinned to 1.52.0 (matches mcr.microsoft.com/playwright:v1.52.0-jammy in docker-compose.yml). waitForGraphLayoutSettled (viewport size poll + double requestAnimationFrame) runs before Analyze workspace screenshots. actionlint pinned to v1.7.7 (CI + Docker script). Workflow lint is a dedicated workflow-lint.yml with paths filters. src/frontend/web/.dockerignore keeps docker compose build web reproducible (host node_modules must not overwrite npm ci).
Phase 77 (CI workflow-lint + clipboard E2E): workflow-lint.yml invokes ./scripts/lint-workflows.sh (Docker rhysd/actionlint:1.7.7) instead of an unresolvable uses: rhysd/actionlint@… action. e2e-smoke copy regression uses installE2eClipboardCapture so tests read the writeText payload from window, not navigator.clipboard.readText() (unreliable in headless CI).
Phase 78 (docs front door + CI parity): Root README.md links hosted docs at https://sempervent.github.io/postgres-query-autopsy-tool/ and points contributors to docs/contributing.md → “CI parity (verified commands)”; stale actionlint:latest wording in historical log entries corrected to the pinned image.
Phase 79 (immutable images + Docker frontend verify): Multi-arch index digests pin Playwright (docker-compose.yml), web build/runtime (node:20-alpine, nginx:alpine), API build/runtime (.NET 8 SDK/ASP.NET), Makefile test-backend-docker, and scripts/verify-frontend-docker.sh. make verify-frontend-docker runs the CI-shaped frontend script in that Node image; make verify-docker chains it with Docker backend tests. src/backend/.dockerignore drops bin/ / obj/ from the API build context. (Phase 81 tightens the script to fixtures:check + full-repo mount — see below.)
Phase 80 (verification path + fallbacks): scripts/lint-workflows.sh defaults to digest-pinned actionlint Docker image; ACTIONLINT_BOOTSTRAP=1 downloads official v1.7.7 binary to .cache/pqat-actionlint/ when Docker is unavailable (Phase 81 adds SHA256 verification against the release checksums file). make repo-health-docker = lint + verify-frontend-docker. CI frontend job pins Node 20.18.0 (with .nvmrc / Volta). Contributor docs add a verification tiers table.
Phase 81 (front door + trust signals): Root README.md is the primary project front door: compact verification table, Docker quick-check snippet, shield row (CI, workflow lint, GitHub Pages docs, MIT, .NET 8, Node 20.18). docs/index.md repeats the same shields so MkDocs / Pages match GitHub. ACTIONLINT_BOOTSTRAP now SHA256-verifies the tarball against upstream actionlint_${ver}_checksums.txt. scripts/verify-frontend-docker.sh bind-mounts the full repo and runs npm run fixtures:check before npm test + npm run build, matching ci.yml frontend. scripts/shellcheck-scripts.sh + make shellcheck-scripts (optional shellcheck on PATH). .github/workflows/ci.yml setup-node adds cache: npm (package-lock.json path). Contributor docs: badge sync, re-pinning playbook, npm audit triage note.
Phase 82 (user-facing explanation + actionability): PlanStoryBuilder uses clearer driver phrasing (primary pressure types), avoids repeating the top suggestion’s body in InspectFirstPath (points users at ranked Optimization suggestions), drops duplicate operatorBriefingLine embed in step 1 (briefing stays on bottleneck cards), and appends a bounded time_bucket hint when SQL suggests bucketing. ComparisonStoryBuilder extends the overview when faster root + worsened pairs or slower root + improved pairs coexist; tightens grouped-output continuity tails. Frontend storyPresentation renames plan/compare section eyebrows for scan-first reading; Analyze summary adds a short reading order hint; Plan guide hotspots no longer duplicate the full Start here paragraph; suggestion cards sort by priority → confidence, elevate Try next, dedupe identical summary/action lines, and mark the top leverage card; selected node shows bottleneck posture when the node is on a ranked bottleneck; Compare change briefing gains an interpretive subtitle. Markdown/HTML report plan-briefing labels align with the UI.
Phase 83 (frontend CI reliability + structured next steps): src/frontend/web/package.json optionalDependencies pins @rolldown/binding-* per platform (same version as the Rolldown stack) so GitHub Actions npm ci materializes linux-x64-gnu bindings for Vitest/Vite 8. PlanStory adds InspectFirstSteps (JSON inspectFirstSteps); InspectFirstPath remains a joined legacy string. PairRewriteVerdictBuilder + NodePairDetail.RewriteVerdictOneLiner give Compare a one-line, evidence-bound rewrite outcome; low mapping confidence prefixes Weak mapping— when metric clauses are still shown. PlanAnalysisService Markdown/HTML reports emit numbered Start here lists when steps exist. UI: Start here lane renders ordered steps + optional Focus in plan; suggestion cards add a Bottleneck #N jump chip when relatedBottleneckInsightIds resolves.
Phase 84 (clipboard trust + reporting + issues): copyToClipboard uses synchronous execCommand('copy') first, then navigator.clipboard.writeText, fixing user-gesture loss after await in strict browsers. useCopyFeedback uses globalThis.setTimeout and clearer failure copy; copy status regions expose aria-live. suggestionReferenceText + Copy for ticket on suggestion cards. POST /api/compare/report/html + RenderCompareHtmlReport (top pairs + Rewrite outcome when verdict exists). Root ISSUE.md + .github/ISSUE_TEMPLATE/bug_report.md. Vitest setup.ts / integration tests align with the real copy order; Playwright clipboard capture records both paths.
Phase 86 (export parity + copy payloads + a11y): Analyze HTML suggestions align with markdown (id, Why, Validate); Headline Findings typo fixed. Compare HTML adds Next steps after this change when compare suggestions exist; compare markdown suggestions gain family + optional Why. pairReferenceText / nodeReferenceText / suggestionReferenceText carry PQAT compare: / PQAT analysis: scope lines and clearer Plan A/B node + Pair artifact tails; compareDeepLinkClipboardPayload / analyzeDeepLinkClipboardPayload stack URL + ids for Copy link. Compare summary Copy for ticket on compare suggestions. Copy status regions add aria-atomic="true"; success dwell ~2.2s. Tests: AnalyzeReportExportTests, extended CompareReportHtmlTests, Vitest shareAppUrl, nodeReferences, optimizationSuggestionsPresentation; Playwright adds Compare deep link + suggested EXPLAIN flows.
Phase 87 (workflow polish + JSON parity + graph tests): Vitest setup.ts fakes offsetWidth/offsetHeight inside .react-flow, implements ResizeObserver callbacks, uses Lines grid in tests only (AnalyzePlanGraphCore), guards setCenter, and briefly filtered a transient React NaN-attribute console line. Phase 88 removed that global filter and hardened useCopyFeedback teardown so copy success timers do not throw after unmount. Compare markdown Next steps heading matches HTML. ReportJsonExportParityTests + heading parity test protect JSON/report shape. Playwright adds Compare Copy for ticket + Analyze Copy share link.
Phase 89 (compare deep links + graph noise): analyzeGraphAdapter + AnalyzePlanGraphCore coerce finite node positions; GraphSync defers fitView until the viewport is finite. AnalyzePage.interaction.test.tsx applies a file-local console.error filter for the known React NaN SVG attribute warning only. Playwright adds Compare Copy link reopen (pair= + Rewrite outcome) and same-pair suggestion Copy for ticket (Pair scope: line). Compare Next steps section landmark; useCopyFeedback unmount regression tests.
Phase 90 (compare collaboration + semantics): Compare heading ladder (h1 capture → h2 summary / navigator / pair / top changes); Next steps suggestion lines use h3 titles with Pin after Focus plan B / Copy for ticket in DOM (keyboard-friendly Tab order). Playwright persisted-flows: suggestion= highlight reopen in a second tab; jwt-auth-smoke: Compare Copy link clipboard under JWT. AnalyzePlanGraphCore defaultViewport: explicit { x:0, y:0, zoom:1 }; NaN stderr may still require the Analyze interaction file filter in Vitest.
Phase 91 (frontend CI + proxy clipboard parity): GitHub Actions frontend uses scripts/verify-frontend-docker.sh (same npm ci, fixtures:check, Vitest, Vite build as Makefile verify-frontend-docker). e2e-auth-proxy: Compare Copy link clipboard test aligns with JWT. Compare Pin aria-describedby suggestion title id; share vs pair Copy tooltips.
Phase 92 (visual + finding deep link): e2e-visual Analyze happy-path snapshot uses analyze-visual-summary-contract (metrics + structured plan briefing), excluding share/metadata blocks. Playwright persisted-flows covers finding= reopen highlight parity with suggestion=.
Phase 93 (deep links + copy + jsdom graph): indexDiff= reopen/highlight E2E ( compare-index-changes-callout ); finding= / indexDiff= URL assertions; suggestion= reopen asserts settled query preservation. shareAppUrl.compareDeepLinkClipboardPayload adds optional Pinned … lines. Vitest setup.ts patches getBoundingClientRect inside .react-flow when the rect is empty (complements offset stubs; AnalyzePage.interaction no longer filters console.error for NaN attributes).
Phase 94 (pinned copy + export + suggestion docs): Playwright Copy link with pinned index insight (clipboard + URL round-trip); indexDiff= toBeInViewport on Index changes; JWT smoke asserts Pinned index insight in auth mode. Compare HTML export gains Index changes (markdown heading unified to ## Index changes). resolveCompareSuggestionParamToCanonicalId JSDoc + E2E seed comment clarify suggestion= / alsoKnownAs behavior.
Browser E2E (Phase 50–57): Playwright smoke exercises persisted ?analysis= / ?comparison=, ?node=, ?suggestion= alias resolution, 422/409 error UI, and staged Compare pair hydration. Phase 52: e2e-auth-api-key. Phase 53: e2e-auth-jwt. Phase 54: e2e-auth-proxy. Phase 56–57: e2e-visual — canonical pixel checks under e2e/visual/. Phase 75: e2e-visual uses region screenshots (Analyze summary/workspace/findings; Compare summary/navigator/pair; error banner only) so layout growth does not invalidate giant full-page baselines. Compose: api + web by default; --profile testing playwright; PQAT_E2E_ENABLED gates /api/e2e/seed/*. See Contributing.
The frontend communicates with the backend via typed API calls:
- Analyze page: paste/upload plan JSON; Phase 49 maps 422/409 artifact GET failures to explicit UI copy (corrupt vs unsupported schema vs not found vs access denied). Phase 39+42 lay out results as a workspace: graph/text tree + Plan guide rail (AnalyzePage, three-tier layout via useWorkspaceLayoutTier: narrow <900px stacks, medium 900–1319px keeps side-by-side graph+guide with tuned column ratios, wide ≥1320px emphasizes the investigation surface), then lower band for findings + suggestions + selected node; Phase 97 makes medium/wide graph + guide a single shared-height row (pqat-analyzeWorkspaceRow--paired, companionRailSurfaceStyle, graphFillColumn on AnalyzePlanGraphCore, internal guide scroll) so the rail is not max-height-stranded beside a taller workspace. Phase 98: beside-workspace Plan guide may lift Selection snapshot into pqat-planGuideRail__stickyBand; Compare adds aria-live pin announcements (comparePinLiveAnnouncement) and Playwright keyboard coverage for Index changes + Next steps Home/End. Phase 99: Text mode in paired Analyze uses pqat-planTextTreeBand; Plan guide scroll body aria-label when beside workspace; Compare hydrate vs transition pin copy (comparePinHydrateAnnouncementForFingerprint), PIN_LIVE_ANNOUNCE_DEFER_MS vs COPY_FEEDBACK_SUCCESS_CLEAR_MS, shared keyboard aria-describedby hints, preventScroll on roving focus, and multi-row Index E2E (rewrite_sort_seq_shipments / rewrite_index_ordered_shipments). Phase 100: parseCompareUrlPinAndPairState resyncs pins + pair= when the query string changes without a new snapshot; hydrate aria-live lines auto-clear via useEffect + COMPARE_PIN_HYDRATE_CLEAR_MS; Playwright covers hydrate + Space pin + indexDiff URL churn. The graph is AnalyzePlanGraphLazy wrapping AnalyzePlanGraphCore (React Flow) in a lazy chunk with PlanGraphSkeleton while Text mode avoids loading that chunk until Graph is chosen (idle + Graph control hover/focus prefetch). Phase 42 widens the app shell (fluid max-width in App.css). Phase 43 applies workstation.css + design tokens. Phase 44 adds workstation-patterns.css, React.lazy for AnalyzePage, and defers AnalyzeWorkspaceCustomizerInner until the customizer <details> opens (with RouteFallback / CustomizerBodyFallback loading UI). Phase 45 adds lower-band lazy chunks (findings / suggestions / selected node), HeavyPanelShell + LowerBandPanelSkeleton, VirtualizedListColumn for long findings and suggestion lists, and AnalyzeSelectedNodeHeavySections lazy-loaded under the selected-node header. Phase 48 keeps family group headers inside the virtualized optimization list via flattened rows + getItemSize, and normalizes older persisted suggestion JSON for display.
- Compare page: submit two plans, show diff-aware narrative, changed findings, and a synchronized branch context strip (see compareBranchContext + CompareBranchStrip). ?suggestion= deep links resolve through alsoKnownAs (Phase 49 server aliases + client resolveCompareSuggestionParamToCanonicalId) so legacy bookmarked ids still highlight the canonical row. Phase 41 decomposes the page into components/compare/*, adds compareWorkspace/ (layout model + useCompareWorkspaceLayout, localStorage + optional compare_workspace_v1 user preference), and workspaceLayout/reorder.ts shared with Analyze for neighbor swaps. Phase 42 applies the same layout tiers to summary + main grids and uses components/workspace/WorkspaceSortableOrderList (@dnd-kit drag + Up/Down fallback). Phase 43 aligns Compare surfaces with pqat-*. Phase 44 refines capture/advanced blocks into shared patterns, React.lazy for ComparePage, and CompareWorkspaceCustomizerInner split for on-open loading of DnD lists. Phase 45 windowizes long findings diff lists in CompareNavigatorPanel with the same VirtualizedListColumn helper (thresholded; small diffs stay a plain list). Phase 46 lazy-loads CompareSelectedPairHeavySections (access path, corroboration, join/context summaries, evidence grids, metric deltas, pair findings, matcher diagnostics) under CompareSelectedPairPanel with a Suspense skeleton (pqat-pairHeavySkeleton), while the pair title, copy actions, optional compare next-step, and confidence line stay eager; ComparePage shrinks slightly as heavy pair UI moves to its own chunk. Phase 48 adds prefetchCompareSelectedPairHeavySections() (coalesced dynamic import) on ClickableRow onPointerIntent from navigator pair rows, findings diff rows, What changed most, Branch context mapped rows, requestIdleCallback after a comparison loads, and Focus plan B hover/focus on summary suggestions—mirroring Analyze graph idle/hover prefetch—plus a softer skeleton entry animation.
Frontend presentation layer (Phase 12)¶
The UI uses a small presentation helper layer to keep human-readable labeling consistent and to avoid leaking backend-internal ids into primary UX:
- src/frontend/web/src/presentation/nodeLabels.ts: node and pair display labels/titles (sort labels align with backend “feed relation + sort key” when byId is present)
- src/frontend/web/src/presentation/planReferencePresentation.ts: normalize Phase 61 story beats vs legacy string arrays; Phase 62 humanNodeAnchorFromPlan for compare focus labels without raw paths
- src/frontend/web/src/presentation/briefingReadoutPresentation.ts (Phase 63): trims operatorBriefingLine for display and shared Briefing strip copy in Analyze/Compare readouts
- src/frontend/web/src/presentation/contextBadges.ts: contextDiff-driven badges for scanability
- src/frontend/web/src/presentation/comparePresentation.ts: compare intro copy, summary/coverage phrases, and top-change callouts
- src/frontend/web/src/presentation/compareBranchContext.ts: builds the selected-pair branch view model (paths, children, mapping/unmatched flags, focal cues) from PlanComparisonResult + matches
- src/frontend/web/src/presentation/workerPresentation.ts: worker summary line + table row shaping for parallel workers[] on Analyze selected node
- src/frontend/web/src/presentation/indexInsightPresentation.ts: plan overview line, per-node insight cards, compare access path family cue (identity.accessPathFamilyA/B from API)
- src/frontend/web/src/presentation/optimizationSuggestionsPresentation.ts: family + category labels, readable confidence/priority fragments (no Label: value soup in primary UI), metadata sentence helper, grouping helper for long lists, flattened virtual rows (header + card) + per-row size hints for VirtualizedListColumn, display normalization for older persisted payloads missing Phase 47 fields, sort order for suggestions (Phase 32 + Phase 47 + Phase 48)
- src/frontend/web/src/presentation/bottleneckPresentation.ts (Phase 58–59): short labels for bottleneck kind, human phrases for bottleneckClass / causeHint, stable sort by rank for the Plan guide Main bottlenecks block; suggestion cards resolve relatedBottleneckInsightIds to those rows when present
- src/frontend/web/src/components/CompareBranchStrip.tsx: compact twin-column UI wired to the same selection state as the navigator and findings diff
- src/frontend/web/src/components/ClickableRow.tsx + ReferenceCopyButton.tsx: shared row navigation + copy affordances without nested <button> markup; ClickableRow supports selected + selectedEmphasis (fill vs accent-bar) for Compare rows that sit on tinted backgrounds, optional onPointerIntent (mouse enter + focus) for lightweight prefetch hooks (Phase 48 Compare pair heavy chunk), and Phase 72 ignores clicks from nested interactive elements / data-pqat-row-no-activate
- src/frontend/web/src/presentation/copyToClipboard.ts + useCopyFeedback.ts: clipboard write with execCommand fallback and user-visible failure copy (Phase 72)
- shareAppUrl.ts: appUrlForPath for origin + pathname on copy-link actions (Phase 73)
Raw node ids remain available via optional “debug” details, but primary surfaces prefer human-readable labels.
Join/branch naming (Phase 13): - Join-family operators get branch-aware labels and subtitles derived from child structure: - Hash Join: build (hash child input) vs probe (left child) - Nested Loop / Merge Join: outer vs inner (left/right) - Subtitles optionally include a concise join condition snippet when present. - Guardrail: when child structure is ambiguous, the UI falls back to left/right rather than fabricating certainty.
Side-attributed join hints (Phase 14):
- Goal: when and only when evidence is explicitly side-scoped, the UI and compare evidence lines can attribute change to a join side.
- Semantics:
- Hash Join: contextDiff.hashBuild is treated as build-side evidence (child Hash build characteristics).
- Nested Loop: contextDiff.nestedLoop.innerSideWaste (when present) is treated as inner-side evidence (propagated from inner scan waste); otherwise only a conservative inner pressure hint is emitted from amplification direction.
- Guardrails:
- No side attribution is emitted for joins unless the underlying evidence model is inherently side-scoped (e.g., Hash build, inner-side waste).
- Merge Join currently avoids side attribution to prevent guessing.
Narrative/hotspot presentation + query text (Phase 15):
- Backend narrative hotspot strings avoid internal node ids by formatting hotspot references with operator/relation-aware labels.
- Frontend renders hotspots as structured, clickable “inspect next” items derived from PlanSummary.top*HotspotNodeIds + the presentation label system.
- Optional query text can be supplied on analyze; it is returned in PlanAnalysisResult and surfaced in reports and the Analyze UI as a collapsible “Source query” section.
Analyze and Compare share-links are backed by a local SQLite file (configurable path; Docker mounts a volume on /app/data by default). Phase 37 adds optional auth + per-artifact ACL (StoredArtifactAccess: owner, scope, groups, link flag) while keeping non-auth mode as the default (capability URLs, no identity). See Deployment & auth. Docker Compose runs both services locally with a named volume so ids can survive container restarts when the volume is kept.
Architecture diagrams¶
Analysis pipeline¶
IndexSignalAnalyzer (overview + bounded insights) feeds both findings-related UI and OptimizationSuggestionEngine in code; the diagram keeps the spine readable.
Compare pipeline¶
Phase 36: each analyze half can carry optional queryTextA/queryTextB, explainMetadataA/explainMetadataB, and PlanInputNormalizationInfo per side; POST /api/compare persists PlanComparisonResultV2 and returns comparisonId; GET /api/comparisons/{id} reloads the snapshot.
Operator evidence propagation¶
Presentation layer¶
Phase 33: presentation/artifactLinks.ts centralizes query keys (pair, finding, indexDiff, suggestion, analysis, comparison, node), buildCompareDeepLinkSearchParams / buildAnalyzeDeepLinkSearchParams, and scrollArtifactIntoView for data-artifact targets. Compare syncs a small set of params from selection.
Phase 34: optional ExplainCaptureMetadata on analyze request/response; PlannerCostAnalyzer fills PlanSummary.PlannerCosts from parsed nodes; presentation/explainCommandBuilder.ts + explainMetadataPresentation.ts support the Analyze “Suggested EXPLAIN” UI. Analyze ?node= is kept in sync with selection (deduped replace updates).
Phase 35: PlanInputNormalizer (Core) + PlanInputNormalizationInfo on PlanAnalysisResult; API prefers planText for analyze; shareable ?analysis= + ?node= URL model, Copy share link, inline normalization status.
Phase 36: SQLite artifact store for analyses and comparisons; GET /api/analyses/{id} / GET /api/comparisons/{id} read durable JSON payloads; optional Storage:ArtifactTtlHours and Storage:MaxArtifactRows; Compare planAText/planBText, per-side query text + ExplainCaptureMetadata, UI Plan capture / EXPLAIN context (A vs B), ?comparison= + Copy share link parity with Analyze.
Phase 38: JWT and API key identity modes ( JwtBearer, ApiKey ) with stable owner ids; legacy BearerSubject unchanged; GET /api/config exposes authIdentityKind + authHelp; optional RateLimiting on POST endpoints.
Phase 39: Analyze workspace UX — narrative, hotspots, top findings, and suggestion previews sit in a Plan guide rail adjacent to the graph on wide viewports; compact selection snapshot mirrors graph/hotspot/finding clicks; full findings and Selected node detail remain below with <details> for workers, raw JSON, derived metrics, and operator context to reduce vertical noise; graph height is responsive (AnalyzePlanGraph graphHeight prop).
Phase 40: Analyze decomposition + layout preferences — AnalyzePage orchestrates extracted panels under components/analyze/; analyzeWorkspace/analyzeWorkspaceModel.ts defines AnalyzeWorkspaceLayoutState (visibility, guide section order, lower-band column order, presets); useAnalyzeWorkspaceLayout persists to localStorage and optionally /api/me/preferences/analyze_workspace_v1 when auth + client credentials are present; API adds IUserPreferenceStore / SqliteUserPreferenceStore + user_preference table in the artifact SQLite file. The model is page-keyed and versioned so Compare can reuse the same persistence pattern later.
Phase 41: Compare workspace parity — typed CompareWorkspaceLayoutState, useCompareWorkspaceLayout, components/compare/*, and compare_workspace_v1 preference key mirror Analyze’s customization story.
Phase 42: Workspace density + responsive tiers + richer reorder — useWorkspaceLayoutTier (narrow / medium / wide at 900px and 1320px) drives Analyze and Compare grids; WorkspaceSortableOrderList adds handle-based drag-and-drop reorder with Up/Down keyboard-friendly fallback; expanded presets (Wide graph, Reviewer, Compact on Analyze; Wide pair / wideGraph on Compare). Coercion helpers (coerceAnalyze* / coerceCompare*) keep stored JSON valid when merging.
Phase 43: Visual workstation system — index.css tokens (--surface-*, text rungs, elevation, focus ring) and workstation.css (pqat-* utilities: panels, buttons, segmented toggles, chips, metric tiles, graph frame, customizer well). App.tsx uses NavLink for active route styling; findings use severity chip colors; shell topBar uses backdrop blur and pill links.
Phase 44: Patterns + delivery — workstation-patterns.css holds dense layout/form/callout primitives (grids, capture stack, navigator pair rows, summary shell, route fallback). App.tsx lazy-loads AnalyzePage / ComparePage with Suspense; workspace customizers mount WorkspaceSortableOrderList (@dnd-kit) only after Customize workspace opens via React.lazy inner modules. Vitest setup.ts preloads inner modules so Suspense resolves in jsdom.
Data flow¶
Raw pasted planText (optional psql wrapper / + wraps)
→ PlanInputNormalizer (Phase 35) → JSON string
→ JsonDocument.Parse / legacy plan body
Raw plan JSON
→ PostgresJsonExplainParser (normalize)
→ DerivedMetricsEngine (annotate)
→ FindingsEngine (rules + ranking)
→ PlanSummaryBuilder + NarrativeGenerator
→ PlanAnalysisResult (API response, report input, UI model)
Operator Depth v2 (Phase 9): - The parser/normalized model also captures operator-specific fields (sort/hash/parallel/waste/cache) when present. - These fields flow through analysis unchanged and are consumed by: - findings evidence (stronger, more concrete explanations) - compare pair detail (side-by-side operator specifics) - diagnostics and narrative (grounded hints)
Operator evidence propagation (Phase 10):
- DerivedMetricsEngine attaches compact contextEvidence per analyzed node via OperatorEvidenceCollector.
- Context evidence is curated and bounded (nearby descendants only) to avoid flooding payloads.
- Consumers:
- findings rules can reference contextual evidence to explain parent operators using child/subtree signals
- compare pair details expose contextEvidenceA/B for side-by-side context inspection
- narrative/reports can cite short contextual hints when present
Context evidence diff summarization (Phase 11):
- ComparisonEngine computes contextDiff per matched pair from contextEvidenceA/B.
- contextDiff.highlights is the primary “what changed” signal for:
- compare selected-pair UX (Context change summary)
- compare narrative
- compare markdown report
This keeps summaries bounded and avoids dumping raw context into prose.
Comparison pipeline¶
Plan A text/JSON + Plan B text/JSON (optional per-side query + explain metadata)
→ analyze A + analyze B (same pipeline as above; per-side normalization when text path used)
→ NodeMappingEngine (heuristic mapping + confidence)
→ ComparisonEngine (per-node deltas, improved/worsened areas, findings diff with diffId (fd_*) + id-based cross-links, index comparison via IndexComparisonAnalyzer with insightDiffId (ii_*), FindingIndexDiffLinker for reciprocal ids (legacy index arrays retained), pair pairArtifactId (pair_*), corroboration cues, evidence-based narrative, pair details with index delta cues, BottleneckComparisonBrief, Phase 60 ComparisonStory)
→ PlanComparisonResultV2 (API response, UI model, compare report input; includes IndexComparison summary, comparisonStory, bottleneckBrief)
Diagnostics mode (optional):
- POST /api/compare?diagnostics=1 includes bounded candidate + decision diagnostics (winner factors + near-misses).
Compare reports:
- POST /api/compare/report/json returns the structured comparison object (optionally with diagnostics).
- POST /api/compare/report/markdown returns a human-readable compare report (top pairs + key findings changes + limitations).
- POST /api/compare/report/html returns a compact HTML compare report; Top worsened/improved lines include Rewrite outcome when present, plus plan capture (per side), change briefing / story beats when applicable, bottleneck posture, narrative, and Next steps after this change when compare suggestions exist (Phase 88).
Phase 101 (onboarding / help UX): Frontend src/frontend/web/src/help/ — helpSurface.css (pqat-help-* tokens and shells, not pqat-panel), WorkflowGuideShell, WorkflowGuideBar, AnalyzeWorkflowGuide, CompareWorkflowGuide. App.tsx imports the stylesheet globally. Analyze/Compare pages own toggle state (empty vs loaded snapshot, Compare respects workspace intro default). pqat-help-inline contextual callouts live next to high-friction controls (capture, graph/text mode, compare copy actions). CompareIntroPanel removed; docs describe pair= removal semantics and a compact URL lifecycle matrix.
Phase 102 (help discoverability): workflowGuidePrefs.ts — pqat_workflow_guide_v1 dismissal flags, WorkflowGuideQueryParam (guide = 1 / true), URL strip on guide close (transition guard). workflowGuideHotkey.ts — ? / Shift+/ when not typing in form fields. Playwright persisted-flows covers ?guide=1, URL strip, Compare toggle, and hotkey ignore-in-input + synthetic ? on window. Vitest covers prefs, hotkey targets, and data-pqat-help-surface on the guide shell.
Phase 103 (help lifecycle / support): workflowGuideEntryUrl.ts + WorkflowGuideCopyGuidedRow (guide shell footer). WorkflowGuideShell — focusable guide title + pqat-help-shell class contract. AnalyzePage / ComparePage — merged keydown: ? opens with pending focus-to-title; Esc closes with dismissal + toggle refocus (ignored in text fields); explicit open/close uses pending*FocusTitleRef / explicit*ClosePendingRef so programmatic closes (e.g. after analyze/compare) do not steal focus. WorkflowGuideBar — toggleRef, native title tooltip for ? / Esc. Playwright: reload persistence for Analyze + Compare, Copy guided link clipboard, Esc + focus return. Vitest: workflowGuideEntryUrl.test.ts, Esc + focus + pqat-help-shell class.
Phase 104 (help a11y / guided URL): workflowGuideFocusLoopHelpers.ts — collect focusables + Tab wrap handler; wired from WorkflowGuideShell when keyboardContain (explicit opens only). Guide title tabIndex={0} for natural tab order with footer button. buildCopyGuidedLinkUrlFromLocation merges guide=1 into current Location. AnalyzePage / ComparePage — queue*GuideLiveMsg, data-testid="*-workflow-guide-announcer". Vitest: workflowGuideFocusLoopHelpers.test.ts, merged URL tests, shell not pqat-panel, announcer interaction test. Compare E2E patches intro: true on existing workspace JSON when present.
Phase 105 (help landmarks / guided-link clarity / browser announcer): WorkflowGuideShell — explicit role="region"; footer role="group" aria-label="Guided link sharing". WorkflowGuideCopyGuidedRow — merged + entry guided copy (buildCopyGuidedLinkUrlFromLocation vs buildWorkflowGuideAbsoluteUrl). ComparePage — ?guide= no longer seeds initial useState (matches AnalyzePage / readAnalyzeGuideInitialOpen pattern). Playwright persisted-flows: announcer text for open/close/?guide= after dismiss; clipboard asserts for entry vs merged. Vitest: region/footer contract, ?guide= announcer via MemoryRouter.
Phase 106 (guided parity / first paint / visual contract): workflowGuideOpenFromUrl.ts — openWorkflowGuideWhenUrlRequests; Analyze + Compare call it from useLayoutEffect (avoids ?guide= one-frame flash; keeps announcer transition). WorkflowGuideShell — data-pqat-help-visual-contract. Playwright persisted-flows: Compare merged guided goto round-trip; e2e-visual: analyze-workflow-guide-shell.png. Vitest: workflowGuideOpenFromUrl.test.ts.
Phase 107 (cognitive load / examples / triage): src/examples/ — curated ?raw JSON for in-app samples; TryPlanExampleChips (-capture / -help test ids). analyzeOutputGuidance.ts / compareOutputGuidance.ts — analyzeTakeawayFromResult, compareLeadTakeaway. Analyze / Compare pages: onAnalyze(override?), onCompare(overrideA?, overrideB?). Summary UI: pqat-takeawayBand, collapsed Plan source + denser plan/compare story behind <details>; user-facing persistence wording.
Phase 108 (scan-first / expanded samples): More bundled src/examples/plans/*.json (three Analyze + two Compare). analyzeFollowUpScanSignals, compareFollowUpDiffSignals; analyze-scan-signals / compare-scan-signals UI; primaryTriageFindingId cascade to Findings; analyze-summary-heading, analyze-optimization-suggestions-panel for stable E2E; plan/compare story lanes further collapsed by default.
Phase 109 (first-screen triage + continuity + compare scan): buildAnalyzeTriageBundle unifies takeaway + scan signals; analyze-triage-deck + ranked Then scan strip + stickyTriageNarrow on narrow layouts; selected node + Plan guide triageEcho echo summary focus; Findings scrollIntoView for primary triage row (guarded for jsdom). Compare compare-triage-deck, Show row + scrollArtifactIntoView (block: 'center'), summaryStickyNarrow, more prose behind <details>. Fourth Analyze sample (nested-loop / join-heavy): nl-join-inner-heavy + fixture sync.
Phase 110 (decision surface + continuity under load): VirtualizedListColumn imperative scrollToIndex + motionPreferences (reduced-motion-aware scroll); Analyze triage row targeting works when findings are virtualized; compareTriagePairBridgeLine + compare-selected-pair-triage-bridge; Analyze suggestions Start here alignment chip; sticky --pqat-sticky-top-offset; calmer sharing/summary copy.
Phase 111 (guided-path cohesion): suggestionAlignsWithAnalyzeTriage widens Analyze suggestion ↔ Start here ties beyond relatedFindingIds; Plan guide jump to primary finding row; selected node top-next cue when aligned; useStickyTopOffsetSync derives sticky offset from .topBar. Compare bridge covers change story beats; navigator briefing cues (briefingHighlightPair); pair panel Context / Suggested follow-up chain; product-facing copy on summary, sharing, and scan actions (Show in list).
Phase 112 (triage continuity + calmer surfaces): firstVirtualRowIndexAlignedWithAnalyzeTriage + AnalyzeOptimizationSuggestionsPanel scrollToIndex keep Start-here–aligned suggestions visible when lists virtualize. triagePrimaryRoute gates Plan guide CTAs (finding row vs jump to plan focus). analyzeExportTriage augments client-side analyze exports (MD/HTML/JSON) with a compact triage envelope; shareAppUrl / artifactLinks adjust clipboard pinned lines. Compare continuityPairFallback, Reading thread summary phrasing, and compareOutputGuidance URL-bridge wording improve coherence without URL-only dead air. artifactErrorPresentation + capture loading copy reduce system tone. Sticky shells add safe-area-inset-top atop measured top-bar offset.
Phase 113 (guided cohesion + Compare export parity): compareExportTriage.ts + exportCompareMarkdown / exportCompareHtml / exportCompareJson (api/client.ts) prepend/inject pqatExportCompareTriage on Compare downloads when plan text is present. resolveComparePairFallbackDisplay + compareContinuityCueIsSpecific tune pair-panel fallback copy. Plan guide triage echo dedupes headline vs summary; ArtifactErrorBanner shows optional artifactErrorBannerNextStep. VirtualizedListColumn supports scrollContainerTestId; Playwright proves long virtualized suggestions path.
Phase 114 (reopen/export cohesion + landing proof): POST /api/compare/report/* accepts optional comparison body (full PlanComparisonResultV2) via CompareExecution.RunForReportAsync—report rendering skips re-compare when the snapshot is supplied. The SPA sends buildCompareReportRequestBody snapshot mode when plan inputs are empty after GET /api/comparisons/{id}. CompareCapturePanel uses per-format busy labels; resolveComparePairFallbackDisplay drops vague filler; Analyze guide echo differentiates overview scan strip vs finding/step threads; Playwright asserts viewport + scroller bounds for analyze-suggestion-card-triage-aligned.
Phase 115 (guided export parity + trust): PlanAnalysisService.RenderCompareMarkdownReport / RenderCompareHtmlReport emit a compact Reading thread lead (BuildCompareReadingThreadLead) so exports are not client-only. nginx.conf raises client_max_body_size for /api/; Playwright uses waitForRequest (not page.route) for large export POSTs. compareExportTriage.ts adds markdownCompareExportSupplement / injectCompareExportSupplementIntoHtml for selection-only context. Compare: reopen with empty plan inputs exports markdown using snapshot payload; AnalyzePlanGuideRail hides triage echo unless Also scan chips or a primary jump action exists; calmer artifactErrorBannerTitle strings.
Phase 116 (Analyze export parity + trust surfaces): ArtifactPersistenceJson.ApplyToHttpSerializerOptions centralizes HTTP JSON alignment with persistence options. AnalyzeCapturePanel adds a Download band (analyze-export-*) with snapshot cue, busy labels, and status text; formatApiErrorResponse improves export 4xx copy; filterTriageEchoScanLabels dedupes guide Also scan chips vs Start here; compareContinuityCueIsSpecific rejects a few vague short tokens. Playwright Analyze: reopen with empty plan input exports markdown using snapshot payload.
Phase 117 (export failure clarity + parity): UseExceptionHandler in Program.cs returns JSON 400 (request_body_invalid / export_request_incomplete) or 500 (internal_error) for failures on /api/report/* and /api/compare/report/* instead of opaque empty bodies. formatApiErrorResponse surfaces server message lines for those export codes without echoing raw error tokens. Compare export success hints align with Analyze; export status regions add polite live announcements; Plan guide overview echo omits redundant framing when Also scan chips are present. Backend ReportExportInvalidBodyApiTests lock the contract.
Phase 118 (stable contracts + ProblemDetails): ReportExportValidation.TryAnalyzeReportRequest returns Results.BadRequest for export_request_incomplete (no BadHttpRequestException / message sniffing). ReportExportBadRequestExceptionHandler (IExceptionHandler) maps BadHttpRequestException on report paths to request_body_invalid. AddProblemDetails + UseExceptionHandler() replace the bespoke catch-all 500 JSON block. ReportExportValidationTests + extended Playwright (Compare reopen→HTML export, Analyze visible export-failure status) + formatApiErrorResponse 413 JSON codes. ArtifactSharingPanel copy pass.
Phase 119 (export parity + take-with-you UX): Playwright proves Analyze reopen→HTML and Compare reopen→JSON snapshot exports; capture panels use Take with you eyebrow and shorter hints; formatApiErrorResponse calms ProblemDetails-style 500 bodies; Plan guide triage echo suppresses redundant headline whenever Also scan chips exist; sharing save confirmation Sharing updated.
Phase 120 (final reopen parity + handoff): Playwright adds Analyze reopen→JSON (POST /api/report/json); Take with you bands add a one-line Markdown/HTML/JSON legend (analyze-export-format-legend / compare-export-format-legend); ArtifactSharingPanel artifact-sharing-effect-note + Sharing saved… copy; filterTriageEchoScanLabels / compareContinuityCueIsSpecific reduce weak duplicate guidance; empty-body 400 export failures get a clearer formatApiErrorResponse line.
Phase 121 (action surfaces + trust copy): pqat-handoffBand / exportDownloadSuccessHint unify Analyze/Compare export success; formatApiErrorResponse handles empty 401/413 consistently; ArtifactSharingPanel post-save + effect note polish; compareContinuityCueIsSpecific short faster/plan path + tests for resolveComparePairFallbackDisplay.
Phase 122 (readability + graph→finding thread): Smaller default graph frame; Analyze paired grid shifts weight toward the plan guide column; topRankedFindingForNode + graphPivotFindingId link graph selection to the findings list; Selected node analyze-related-findings-bridge chips; AnalyzeFindingsPanel graph pivot row styling; Vitest graph node click interaction test.
Phase 123 (graph-local findings): AnalyzeLocalFindingsShelf in Plan workspace + selected-node detail; rankedFindingsForNode; graph jumpToNodeId does not auto-scroll the ranked list — See in ranked list sets graphPivotFindingId; findings row cue Opened from detail.
Phase 124 (dedupe + navigator): AnalyzeLocalFindingsBridge when workspace + selected-node visible; shelf lead/secondary hierarchy + a11y; Playwright asserts graph click does not show ranked-list Opened from detail until explicit See in ranked list.
Phase 125 (workspace reading path + local evidence): pqat-investigationThread on selected-node readout; shelf Why this operator matters / Strongest match / Full write-up in list; bridge adapts single vs multi (localEvidencePresentation max severity); truncation cue under shelf when previews < total; findings intro aligned; Playwright proves analyze-local-evidence-open-top-in-list → Opened from detail.
Phase 126 (flow + evidence consistency): evidenceNavCopy + aria helpers; SkipToRankedFindingsLink → #analyze-ranked-findings; AnalyzeFindingsPanel + AnalyzePlanGuideRail use shared severityLabel / chips from localEvidencePresentation; shelf truncation + Other titles; responsive polish on investigation band.
Phase 127 (focus + language parity): Skip link scroll + focus on Ranked panel; graphPivotFindingId path focus() with retry on the ranked ClickableRow scoped under #analyze-ranked-findings (shelf preview li[data-finding-id] no longer shadows querySelector); findingConfidenceLabel; preview vs full aria cues; nav.pqat-planEvidenceShortcuts.
Phase 128 (evidence semantics + arrival): Preview shelf data-pqat-preview-finding-id vs ranked data-finding-id; queryRankedFindingRow; virtual scrollToIndex onSettled; pivot cue timer tied to focus arrived + 6s fallback (Phase 129); graph-pivot row focus-visible + skip :focus-within stacking.
Phase 129 (arrival confidence): scheduleVirtualScrollSettled module + tests; ranked arrival cue + aria-describedby on Findings h2; graph legend repositioned vs skip link; pivot fallback 6s; skip data-testid + E2E pointer path.
Phase 130 (ranked continuation + signal): pqat-rankedFindingsPanel--pivotContinuity; calmer left-stripe arrival cue; graph-pivot row wash; scroll-margin-top on #analyze-ranked-findings; Vitest getBBox shim + React SVG NaN console filter; AnalyzePlanGraphCore dots-only Background; skip E2E focus + click.
Phase 131 (cross-surface continuation): Analyze Continues from plan thread hint (trimmed duplicate operator / row cue); comparePairHandoffKind + compare-visual-pair-continuation-contract; e2e-visual analyze-ranked-continuation-contract + compare-pair-continuation-contract; NaN console.error filter gated on .react-flow in document.
Phase 132 (responsive + restored continuity): compareHandoffOrigin + comparePairHandoffDisplayText (comparePairHandoffCopy.ts); narrow Compare DOM order navigator→pair; narrow Analyze lower-band reading order; continuation role="region" + aria-labelledby wiring; Playwright reopen asserts data-pqat-handoff-origin link.
Phase 133 (legible provenance + narrow flow): Summary — reopened / Briefing — reopened for link + summary/briefing; SkipToPairInspectorLink → compare-pair-inspector-region; pair + Analyze ranked continuation aria-describedby on role="region" without duplicating thread text on h2; visual corrupt seed retry; Playwright visible reopen cue.
Phase 134 (Analyze reopen parity + keyboard proof): analyzeRankedHandoffOrigin + analyzeRankedContinuityCopy (Ranked — reopened, Continues from plan — reopened); Compare link pinned/navigator → Pinned — reopened / From the lists — reopened; Playwright narrow Enter on skip → #compare-pair-inspector-region focused; matrix Vitest + reopen E2E.
Phase 135 (breadth + model): withReopenedSuffix (reopenedContinuityCopy.ts); exportDownloadSuccessHint restoredFromLink; compareContinuityCueIsSpecific join-strategy shift gate for Reading thread pair fallback; Playwright Briefing — reopened (NL→hash fixtures + continuity panel) + narrow Tab chain to skip; compact handoff matrix in docs/compare-workflow.md.
Phase 136 (stability + proof): AnalyzePage analyze useLayoutEffect URL sync; compareContinuityCueSpecificity.ts + fixture table tests; Playwright From the lists — reopened (nested-loop compare fixtures) + toHaveURL gates before page.url() on Analyze/Compare persist paths.
Phase 137 (graph-first issue readout): AnalyzeGraphIssueSummary + graphNodeIssueSummaryPresentation under AnalyzePlanWorkspacePanel (pqat-graphInvestigationStack); workspace AnalyzeLocalFindingsShelf compactWorkspacePreview + demoted AnalyzeLocalFindingsBridge; default graph clamp(240px, 30vh, 420px); Playwright graph-click asserts in-band summary and no Ranked focus until explicit pivot.