From f709d519ec0db5d2a767ca4214ae6c33b0cd84b1 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 00:02:02 +0200 Subject: [PATCH] feat(ui): ship contextual action primitives --- ...E_contextual_actions_and_stray_surfaces.md | 66 ++-- .../web/contextual-actions-patterns-ui.md | 55 ++++ docs/modules/ui/README.md | 2 + docs/modules/ui/TASKS.md | 13 +- .../RESTORATION_PRIORITIES.md | 8 +- .../ui/contextual-actions-patterns/README.md | 31 ++ docs/modules/ui/implementation_plan.md | 2 +- docs/modules/ui/restoration-topics/README.md | 8 +- .../platform-ops-consolidation.md | 2 +- .../reachability-witnessing.md | 2 +- .../triage-explainability-workbench.md | 2 +- .../platform-ops-overview-page.component.html | 47 +-- .../platform-ops-overview-page.component.ts | 59 ++-- .../reachability-center.component.html | 86 ++---- .../reachability-center.component.ts | 82 +++-- .../watchlist/watchlist-page.component.html | 86 ++---- .../watchlist/watchlist-page.component.ts | 93 +++--- .../run-graph-replay-page.component.html | 88 ++---- .../run-graph-replay-page.component.ts | 75 ++++- .../context-drawer-host.component.ts | 243 +++++++++++++++ .../context-header.component.ts | 153 +++++++++ .../context-route-state.ts | 81 +++++ .../StellaOps.Web/src/app/shared/ui/index.ts | 5 + .../list-detail-shell.component.ts | 52 ++++ .../overview-card-groups.component.ts | 161 ++++++++++ .../ui/tabbed-nav/tabbed-nav.component.ts | 29 +- .../contextual-actions-patterns-ui.spec.ts | 290 ++++++++++++++++++ .../run-graph-replay-page.behavior.spec.ts | 5 +- .../tests/e2e/watchlist-shell.spec.ts | 6 +- .../e2e/workflow-visualization-replay.spec.ts | 6 +- 30 files changed, 1446 insertions(+), 392 deletions(-) rename {docs => docs-archived}/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md (63%) create mode 100644 docs/features/checked/web/contextual-actions-patterns-ui.md create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts create mode 100644 src/Web/StellaOps.Web/src/tests/shared/contextual-actions-patterns-ui.spec.ts diff --git a/docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md b/docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md similarity index 63% rename from docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md rename to docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md index 7415169ef..4cfcaf4a5 100644 --- a/docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md +++ b/docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md @@ -31,7 +31,7 @@ ## Delivery Tracker ### FE-CA-001 - Implement the shared route-state contract -Status: TODO +Status: DONE Dependency: none Owners: FE Architect, Product Manager Task description: @@ -39,12 +39,12 @@ Task description: - Make the restoration topics consume one working route-state model instead of each inventing bespoke state handling. Completion criteria: -- [ ] Shared route-state helpers exist in code. -- [ ] Restoration topics can consume one route-state contract instead of bespoke state rules. -- [ ] The placement hierarchy remains documented as the policy for using the new helpers. +- [x] Shared route-state helpers exist in code. +- [x] Restoration topics can consume one route-state contract instead of bespoke state rules. +- [x] The placement hierarchy remains documented as the policy for using the new helpers. ### FE-CA-002 - Ship the shared contextual drawer host -Status: TODO +Status: DONE Dependency: FE-CA-001 Owners: Developer, FE Architect Task description: @@ -52,12 +52,12 @@ Task description: - Standardize size, close behavior, route-state binding, keyboard handling, and history interactions in working code. Completion criteria: -- [ ] Drawer host is available for adoption in the restoration features. -- [ ] Route-state open and close behavior works in code. -- [ ] Accessibility and keyboard behavior are verified for the shared host. +- [x] Drawer host is available for adoption in the restoration features. +- [x] Route-state open and close behavior works in code. +- [x] Accessibility and keyboard behavior are verified for the shared host. ### FE-CA-003 - Ship split-view, right-rail, and context-header primitives -Status: TODO +Status: DONE Dependency: FE-CA-001 Owners: Developer, FE Architect Task description: @@ -65,12 +65,12 @@ Task description: - Ensure responsive behavior works in the shipped components rather than remaining a note in docs. Completion criteria: -- [ ] Split-view, right-rail, and context-header primitives exist in code. -- [ ] Panel-stack behavior is usable in the shipped primitives. -- [ ] Responsive fallback behavior works in the adopted surfaces. +- [x] Split-view, right-rail, and context-header primitives exist in code. +- [x] Panel-stack behavior is usable in the shipped primitives. +- [x] Responsive fallback behavior works in the adopted surfaces. ### FE-CA-004 - Ship grouped overview-card and submenu primitives -Status: TODO +Status: DONE Dependency: FE-CA-001 Owners: Product Manager, Developer Task description: @@ -78,12 +78,12 @@ Task description: - Standardize one-card-to-one-route and one-submenu-to-one-owner patterns in working components. Completion criteria: -- [ ] Grouped overview-card primitives exist in code. -- [ ] Submenu patterns are usable by owner shells. -- [ ] Card-to-route and submenu-to-owner behavior is consistent in the shipped implementation. +- [x] Grouped overview-card primitives exist in code. +- [x] Submenu patterns are usable by owner shells. +- [x] Card-to-route and submenu-to-owner behavior is consistent in the shipped implementation. ### FE-CA-005 - Adopt the shared primitives into the restoration features -Status: TODO +Status: DONE Dependency: FE-CA-001 Owners: FE Architect, Developer Task description: @@ -91,12 +91,12 @@ Task description: - Do not count this sprint complete until the primitives are used by the first shipped feature set rather than sitting unused in `shared`. Completion criteria: -- [ ] At least the Watchlist, Reachability, and Triage or Workflow surfaces adopt the shared primitives. -- [ ] Shared primitives replace bespoke implementations where the new restoration work lands. -- [ ] Topic-specific adoption is visible in the shipped feature code. +- [x] At least the Watchlist, Reachability, and Triage or Workflow surfaces adopt the shared primitives. +- [x] Shared primitives replace bespoke implementations where the new restoration work lands. +- [x] Topic-specific adoption is visible in the shipped feature code. ### FE-CA-006 - Verify, document, and enforce shared usage -Status: TODO +Status: DONE Dependency: FE-CA-003 Owners: QA, Documentation author Task description: @@ -104,14 +104,19 @@ Task description: - Update docs so future restoration work treats these primitives as required building blocks, not optional helpers. Completion criteria: -- [ ] Shared verification covers the adopted primitives. -- [ ] Restoration sprints reference and consume the shared primitives. -- [ ] Shared docs are updated to reflect the shipped primitive set. +- [x] Shared verification covers the adopted primitives. +- [x] Restoration sprints reference and consume the shared primitives. +- [x] Shared docs are updated to reflect the shipped primitive set. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-07 | Sprint created to ship the shared primitives that let restored but narrow functionality become usable submenus, tabs, drawers, right rails, and detail pages instead of spawning new top-level products. | Project Manager | +| 2026-03-07 | Implemented shared contextual primitives in `src/app/shared/ui`, including route-state helpers, contextual header, drawer host, split list-detail shell, grouped overview cards, and submenu-capable tabs. | Developer | +| 2026-03-07 | Adopted the shared primitives into Watchlist, Reachability, Operations, and Workflow Replay so the first restoration shells no longer depend on bespoke route-state or layout wiring. | Developer | +| 2026-03-07 | Tier 1 verification passed via `npx ng test --watch=false --include src/tests/shared/contextual-actions-patterns-ui.spec.ts --include src/tests/watchlist/identity-watchlist-management-ui.component.spec.ts --include src/tests/reachability_center/reachability-center.component.spec.ts --include src/tests/platform-ops/platform-ops-overview-page.component.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts` with 24 focused tests passing. | QA | +| 2026-03-07 | Tier 2 verification passed via `npx playwright test tests/e2e/watchlist-shell.spec.ts tests/e2e/reachability-witnessing.spec.ts tests/e2e/operations-consolidation.spec.ts tests/e2e/workflow-visualization-replay.spec.ts --workers=1` with 9 behavior checks passing across the adopted surfaces. | QA | +| 2026-03-07 | Sprint archived after docs sync and checked-feature note publication for the shipped contextual action primitives. | Project Manager | ## Decisions & Risks - Decision: contextual placement is a shared FE concern and should not be reinvented per topic. @@ -122,8 +127,15 @@ Completion criteria: - Mitigation: require responsive fallback rules in the shared primitive contract before implementation begins. - Delivery rule: this sprint is only complete when the shared primitives are implemented and adopted by the restoration features, not when the contract is only documented. - Reference design note: `docs/modules/ui/contextual-actions-patterns/README.md`. +- Decision: the stable cross-shell contract is now `tab`, `panel`, `drawer`, `returnTo`, `scope`, and `view`, implemented centrally under `src/app/shared/ui/context-route-state`. +- Decision: route-aware contextual headers and drawer hosts replaced bespoke implementations in Watchlist, Reachability, Workflow Replay, and Operations before the sprint was closed. +- Docs synced: + - `docs/modules/ui/contextual-actions-patterns/README.md` + - `docs/modules/ui/restoration-topics/README.md` + - `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md` + - `docs/modules/ui/implementation_plan.md` + - `docs/modules/ui/TASKS.md` + - `docs/features/checked/web/contextual-actions-patterns-ui.md` ## Next Checkpoints -- 2026-03-08: confirm placement hierarchy and route-state contract. -- 2026-03-09: freeze drawer, right-rail, split-view, and context-header primitives. -- 2026-03-10: finalize adoption map and QA expectations for the restoration sprints. +- Archived on 2026-03-07 after implementation, adoption, verification, and docs sync. diff --git a/docs/features/checked/web/contextual-actions-patterns-ui.md b/docs/features/checked/web/contextual-actions-patterns-ui.md new file mode 100644 index 000000000..c0a415841 --- /dev/null +++ b/docs/features/checked/web/contextual-actions-patterns-ui.md @@ -0,0 +1,55 @@ +# Contextual Actions Patterns UI + +## Module +Web + +## Status +VERIFIED + +## Description +Shipped the shared contextual placement primitives used to turn restored but narrow functionality into usable owner-shell tabs, drawers, submenu pills, and list-detail layouts instead of standalone products. Watchlist, Reachability, Operations, and Workflow Replay now share one route-state contract, one contextual header model, and one drawer or detail-shell behavior. + +## Implementation Details +- **Shared feature directory**: `src/Web/StellaOps.Web/src/app/shared/ui/` +- **Primary shared primitives**: + - `context-route-state` (`src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts`) + - `context-header` (`src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts`) + - `context-drawer-host` (`src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts`) + - `list-detail-shell` (`src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts`) + - `overview-card-groups` (`src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts`) + - `tabbed-nav` (`src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts`) +- **Shared route-state keys**: + - `tab` + - `panel` + - `drawer` + - `returnTo` + - `scope` + - `view` +- **Adopted surfaces**: + - `Setup > Trust & Signing > Identity Watchlist` + - `Security > Reachability` + - `Ops > Operations` + - `Releases > Runs` + +## E2E Test Plan +- **Setup**: + - [ ] Log in with a user that can access `Setup`, `Security`, `Ops`, and `Releases`. + - [ ] Ensure watchlist, reachability, operations, and release-run fixtures or seeded data exist. +- **Core verification**: + - [ ] Verify shared tab and submenu primitives render the adopted shells without bespoke route-state code. + - [ ] Verify contextual headers preserve return-to-context actions where shells deep-link into another owner flow. + - [ ] Verify route-aware drawers and detail layouts preserve URL state and close behavior. +- **Adoption verification**: + - [ ] Verify Watchlist entries use the shared list-detail and header patterns. + - [ ] Verify Reachability tabs and PoE drill-ins use the shared contextual route-state. + - [ ] Verify Operations overview groups use the shared grouped-card and submenu patterns. + - [ ] Verify Workflow Replay step-detail and evidence handoff keep shared `returnTo` and drawer semantics. + +## Verification +- Run: + - `npx ng test --watch=false --include src/tests/shared/contextual-actions-patterns-ui.spec.ts --include src/tests/watchlist/identity-watchlist-management-ui.component.spec.ts --include src/tests/reachability_center/reachability-center.component.spec.ts --include src/tests/platform-ops/platform-ops-overview-page.component.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts` + - `npx playwright test tests/e2e/watchlist-shell.spec.ts tests/e2e/reachability-witnessing.spec.ts tests/e2e/operations-consolidation.spec.ts tests/e2e/workflow-visualization-replay.spec.ts --workers=1` +- Tier 0 (source): pass +- Tier 1 (build/tests): pass +- Tier 2 (behavior): pass +- Verified on (UTC): 2026-03-07T21:56:33.4410778Z diff --git a/docs/modules/ui/README.md b/docs/modules/ui/README.md index b10ae9331..748b9de14 100644 --- a/docs/modules/ui/README.md +++ b/docs/modules/ui/README.md @@ -22,6 +22,8 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt - Added checked-feature verification for reachability witnessing at `../../features/checked/web/reachability-witnessing-ui.md`. - Shipped the consolidated `Ops > Operations` shell with grouped overview cards, canonical `/ops/operations/*` routes, and legacy `platform-ops` alias cutover. - Added checked-feature verification for operations consolidation at `../../features/checked/web/operations-consolidation-ui.md`. +- Shipped the shared contextual placement primitives for tabs, submenu pills, route-aware drawers, list-detail shells, grouped overview cards, and return-to-context headers under `src/Web/StellaOps.Web/src/app/shared/ui/`. +- Added checked-feature verification for the contextual primitives and their first adopted surfaces at `../../features/checked/web/contextual-actions-patterns-ui.md`. ## Latest updates (2026-02-21) - Runtime mock cutover completed for policy simulation history/conflict/batch flows and graph explorer data loading in `src/Web/StellaOps.Web/src/app/**`. diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 51df1acba..2973be312 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -9,7 +9,6 @@ - `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` -- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - `docs/implplan/SPRINT_20260307_035_DOCS_search_first_final_correction_phases.md` - `docs/implplan/SPRINT_20260307_036_FE_search_first_shell_consolidation.md` - `docs/implplan/SPRINT_20260307_038_FE_live_search_supported_route_execution_matrix.md` @@ -107,9 +106,9 @@ - [DONE] FE-WV-004 Step-detail drawer and deep-link behavior - [DONE] FE-WV-005 Workflow-editor preview reuse boundary - [DONE] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization -- [TODO] FE-CA-001 Freeze contextual placement decision matrix and route-state contract -- [TODO] FE-CA-002 Shared contextual drawer host -- [TODO] FE-CA-003 Split list-detail and right-rail primitives -- [TODO] FE-CA-004 Context header and return-to-context contract -- [TODO] FE-CA-005 Grouped overview-card and submenu patterns -- [TODO] FE-CA-006 Adoption map, QA, and docs sync for contextual action patterns +- [DONE] FE-CA-001 Freeze contextual placement decision matrix and route-state contract +- [DONE] FE-CA-002 Shared contextual drawer host +- [DONE] FE-CA-003 Split list-detail and right-rail primitives +- [DONE] FE-CA-004 Context header and return-to-context contract +- [DONE] FE-CA-005 Grouped overview-card and submenu patterns +- [DONE] FE-CA-006 Adoption map, QA, and docs sync for contextual action patterns diff --git a/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md b/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md index 91796834b..a1b2ffdb3 100644 --- a/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md +++ b/docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md @@ -54,7 +54,7 @@ The order is by confidence that the capability should exist in the final Stella - `Security / Reachability` with evidence-side drill-down links - Notes: - Detailed UX dossier: `docs/modules/ui/reachability-witnessing/README.md` - - Implementation sprint: `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` + - Implementation sprint: `docs-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` ### 4. Platform Ops Consolidation - Type: `merge` @@ -67,7 +67,7 @@ The order is by confidence that the capability should exist in the final Stella - `Ops > Operations` - Notes: - Detailed UX dossier: `docs/modules/ui/platform-ops-consolidation/README.md` - - Implementation sprint: `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` + - Implementation sprint: `docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` ### 5. Triage Explainability Workbench - Type: `merge` @@ -80,7 +80,7 @@ The order is by confidence that the capability should exist in the final Stella - `/triage/artifacts` and `/triage/audit-bundles` - Notes: - Detailed UX dossier: `docs/modules/ui/triage-explainability-workspace/README.md` - - Implementation sprint: `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` + - Implementation sprint: `docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` ### 6. Workflow Visualization And Replay UX - Type: `merge` @@ -290,4 +290,4 @@ These branches probably contain valuable pieces, but the right home needs one mo 8. After that, tackle the big surfacing debt bucket: audit, offline, scanner, quota, topology, trust, unknowns. Detailed topic-shape notes for items 2 through 6 now live under `docs/modules/ui/restoration-topics/`. -The shared placement contract for stray actions, drawers, tabs, and detail pages is captured in `docs/modules/ui/contextual-actions-patterns/README.md` and implementation sprint `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`. +The shared placement contract for stray actions, drawers, tabs, and detail pages is captured in `docs/modules/ui/contextual-actions-patterns/README.md`, shipped implementation sprint `docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`, and verification note `docs/features/checked/web/contextual-actions-patterns-ui.md`. diff --git a/docs/modules/ui/contextual-actions-patterns/README.md b/docs/modules/ui/contextual-actions-patterns/README.md index 636ff388f..3a0df77d9 100644 --- a/docs/modules/ui/contextual-actions-patterns/README.md +++ b/docs/modules/ui/contextual-actions-patterns/README.md @@ -115,6 +115,36 @@ Use stable, predictable query params and child routes instead of ad hoc local st - row or bulk actions that preserve page context - confirmation only for destructive or privilege-sensitive actions +## Shipped Implementation + +The shared contract is now implemented under `src/Web/StellaOps.Web/src/app/shared/ui/` and should be treated as the default building block set for restoration work. + +### Shipped primitives +- `context-route-state` + - central helpers for `tab`, `panel`, `drawer`, `returnTo`, `scope`, and `view` + - includes `readContextRouteState`, `readContextRouteParam`, `buildContextRouteParams`, and `buildContextReturnTo` +- `context-header` + - stable subject header with chips, context note, and return-to-context action +- `context-drawer-host` + - overlay or rail presentation with shared close behavior, escape handling, and testable route-state integration +- `list-detail-shell` + - responsive split list/detail layout for owner shells with one dominant list workflow +- `overview-card-groups` + - grouped overview cards with one-card-to-one-route behavior +- `tabbed-nav` + - now supports both classic tabs and owner-shell submenu pills, plus route command arrays and shared query params + +### First adopted surfaces +- Watchlist uses the shared route-state, contextual header, tabs, and list-detail shell. +- Reachability uses the shared route-state, contextual header, and tabs. +- Operations uses the shared submenu and grouped overview-card patterns. +- Workflow Replay uses the shared route-state, contextual header, tabs, and drawer host. + +### Delivery rule +- New restoration work should adopt these primitives before introducing new feature-local panel or route-state helpers. +- Context-preserving deep links should use `returnTo` instead of bespoke navigation metadata. +- Owner shells should prefer submenu pills, tabs, list-detail layouts, or drawers before creating another top-level route tree. + ## Topic Mapping ### Watchlist @@ -153,3 +183,4 @@ Use stable, predictable query params and child routes instead of ad hoc local st - `docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md` - `docs/modules/ui/architecture.md` - `docs/modules/ui/architecture-rework.md` +- `docs/features/checked/web/contextual-actions-patterns-ui.md` diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 0bc5b3b86..67bf709de 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components. - `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation. - `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself. -- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features. ## Latest evidence - `docs/modules/ui/component-preservation-map/README.md` - root index for the first-pass preservation map. @@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover. - `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover. - `docs/features/checked/web/workflow-visualization-replay-ui.md` - shipped verification note for the canonical run-detail graph, timeline, replay, evidence tabs, and workflow-editor preview reuse boundary. +- `docs/features/checked/web/contextual-actions-patterns-ui.md` - shipped verification note for the shared contextual route-state, headers, drawers, list-detail shells, grouped overview cards, and first adopted restoration surfaces. - `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract. - `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan. - `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier. diff --git a/docs/modules/ui/restoration-topics/README.md b/docs/modules/ui/restoration-topics/README.md index 649b1c8fd..674a794f2 100644 --- a/docs/modules/ui/restoration-topics/README.md +++ b/docs/modules/ui/restoration-topics/README.md @@ -28,11 +28,11 @@ It answers four questions for each topic: ## Implementation Sprint Set - `docs-archived/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md` - shipped watchlist restoration -- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` -- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` -- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` +- `docs-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` - shipped reachability witnessing restoration +- `docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` - shipped platform ops consolidation +- `docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` - shipped triage explainability restoration - `docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md` - shipped workflow visualization and replay restoration -- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` +- `docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - shipped shared contextual primitives ## Placement Matrix diff --git a/docs/modules/ui/restoration-topics/platform-ops-consolidation.md b/docs/modules/ui/restoration-topics/platform-ops-consolidation.md index c355ca8bf..f3b9d48e9 100644 --- a/docs/modules/ui/restoration-topics/platform-ops-consolidation.md +++ b/docs/modules/ui/restoration-topics/platform-ops-consolidation.md @@ -93,7 +93,7 @@ One overview page plus grouped subroutes is enough. ## Detailed UX And Sprint - Detailed UX dossier: `../platform-ops-consolidation/README.md` -- Implementation sprint: `../../../implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` +- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` ## Corroborating Inputs diff --git a/docs/modules/ui/restoration-topics/reachability-witnessing.md b/docs/modules/ui/restoration-topics/reachability-witnessing.md index 72ac2823e..3a7c186f5 100644 --- a/docs/modules/ui/restoration-topics/reachability-witnessing.md +++ b/docs/modules/ui/restoration-topics/reachability-witnessing.md @@ -97,7 +97,7 @@ These should deep-link into the same reachability surfaces: ## Detailed UX And Sprint - Detailed UX dossier: `../reachability-witnessing/README.md` -- Implementation sprint: `../../../implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` +- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` ## Corroborating Inputs diff --git a/docs/modules/ui/restoration-topics/triage-explainability-workbench.md b/docs/modules/ui/restoration-topics/triage-explainability-workbench.md index 601ee4cb9..1f031b21d 100644 --- a/docs/modules/ui/restoration-topics/triage-explainability-workbench.md +++ b/docs/modules/ui/restoration-topics/triage-explainability-workbench.md @@ -108,7 +108,7 @@ These should be secondary tabs or a right-rail stack, not standalone routes. ## Detailed UX And Sprint - Detailed UX dossier: `../triage-explainability-workspace/README.md` -- Implementation sprint: `../../../implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` +- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` ## Corroborating Inputs diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html index 858de532f..5bd9e8c20 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html @@ -23,17 +23,7 @@ - +
@@ -86,36 +76,11 @@ - @for (group of overviewGroups; track group.id) { -
-
-
-

{{ group.title }}

-

{{ group.description }}

-
-
- - -
- } + - + @if (activeTab() === 'coverage') {
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts index d0c96e7c6..15bbe1528 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts @@ -17,6 +17,16 @@ import type { ConfidenceTier, ReachabilityWitness, } from '../../core/api/witness.models'; +import { + ContextHeaderComponent, + type TabItem, + TabbedNavComponent, +} from '../../shared/ui'; +import { + buildContextRouteParams, + readContextRouteParam, + readContextRouteState, +} from '../../shared/ui/context-route-state/context-route-state'; import { PoEDrawerComponent } from './poe-drawer.component'; import { type CoverageStatus, @@ -55,7 +65,13 @@ const TIER_FILTERS: readonly TierFilter[] = [ @Component({ selector: 'app-reachability-center', standalone: true, - imports: [CommonModule, RouterLink, PoEDrawerComponent], + imports: [ + CommonModule, + RouterLink, + PoEDrawerComponent, + ContextHeaderComponent, + TabbedNavComponent, + ], templateUrl: './reachability-center.component.html', styleUrls: ['./reachability-center.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -81,6 +97,12 @@ export class ReachabilityCenterComponent implements OnInit { readonly coverageRows = signal([...REACHABILITY_COVERAGE_ROWS]); readonly gapRows = signal([...REACHABILITY_GAP_ROWS]); readonly witnesses = signal([]); + readonly tabItems: readonly TabItem[] = [ + { id: 'coverage', label: 'Coverage', testId: 'reachability-tab-coverage' }, + { id: 'witnesses', label: 'Witnesses', testId: 'reachability-tab-witnesses' }, + { id: 'poe', label: 'PoE / Exposure', testId: 'reachability-tab-poe' }, + { id: 'gaps', label: 'Sensor Gaps', testId: 'reachability-tab-gaps' }, + ]; readonly filteredCoverageRows = computed(() => { const status = this.coverageStatusFilter(); @@ -243,6 +265,24 @@ export class ReachabilityCenterComponent implements OnInit { void this.navigateToTab('gaps'); } + onTabSelected(tabId: string): void { + switch (tabId as ReachabilityTab) { + case 'witnesses': + this.showWitnesses(); + break; + case 'poe': + this.showPoE(); + break; + case 'gaps': + this.showGaps(); + break; + case 'coverage': + default: + this.showCoverage(); + break; + } + } + openPoeArtifact(artifactId: string): void { this.selectedPoeArtifactId.set(artifactId); void this.navigateToTab('poe', artifactId); @@ -357,29 +397,28 @@ export class ReachabilityCenterComponent implements OnInit { params: ParamMap, queryParams: ParamMap ): void { - const tab = this.parseTab(segments, queryParams.get('tab')); + const tab = this.parseTab(segments, queryParams); this.activeTab.set(tab); - this.returnTo.set(queryParams.get('returnTo')); - this.witnessSearch.set(queryParams.get('search') ?? ''); - this.tierFilter.set(this.parseTier(queryParams.get('tier'))); + this.returnTo.set(readContextRouteParam(queryParams, 'returnTo')); + this.witnessSearch.set(readContextRouteParam(queryParams, 'search') ?? ''); + this.tierFilter.set(this.parseTier(readContextRouteParam(queryParams, 'tier'))); this.selectedPoeArtifactId.set( - tab === 'poe' ? params.get('artifactId') : null + tab === 'poe' ? readContextRouteParam(params, 'artifactId') : null ); } private parseTab( segments: readonly string[], - queryValue: string | null + queryParams: ParamMap ): ReachabilityTab { - if (queryValue && REACHABILITY_TABS.includes(queryValue as ReachabilityTab)) { - return queryValue as ReachabilityTab; - } - const firstRecognized = segments.find((segment) => REACHABILITY_TABS.includes(segment as ReachabilityTab) ); - return (firstRecognized as ReachabilityTab | undefined) ?? 'coverage'; + return ( + (firstRecognized as ReachabilityTab | undefined) ?? + readContextRouteState(queryParams, 'tab', REACHABILITY_TABS, 'coverage') + ); } private parseTier(value: string | null): TierFilter { @@ -406,21 +445,12 @@ export class ReachabilityCenterComponent implements OnInit { } private buildQueryParams(tab: ReachabilityTab): Record { - const params: Record = { + return buildContextRouteParams({ tab, - }; - - if (this.returnTo()) { - params['returnTo'] = this.returnTo()!; - } - if (this.witnessSearch().trim()) { - params['search'] = this.witnessSearch().trim(); - } - if (this.tierFilter()) { - params['tier'] = this.tierFilter(); - } - - return params; + returnTo: this.returnTo(), + search: this.witnessSearch().trim() || null, + tier: this.tierFilter() || null, + }) as Record; } private async verifyWitnessFallbackAware( diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html index 9ad3953fe..30bcc6e35 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html @@ -1,25 +1,22 @@
- + + + @if (message()) {
- + @if (activeTab() === 'entries') {
@@ -132,8 +108,8 @@ -
-
+ +
@if (entriesLoading() && !entries().length) {
Loading watchlist rules...
} @else if (!filteredEntries().length) { @@ -218,7 +194,7 @@
@if (entryPanelMode()) { -
} - +
} @@ -422,8 +398,8 @@ -
-
+ +
@if (alertsLoading() && !alerts().length) {
Loading watchlist alerts...
} @else if (!filteredAlerts().length) { @@ -481,7 +457,7 @@
@if (selectedAlert(); as alert) { -
+ } diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts index 764b3e712..83cf374f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts @@ -32,6 +32,17 @@ import { WatchlistMatchMode, WatchlistScope, } from '../../core/api/watchlist.models'; +import { + ContextHeaderComponent, + ListDetailShellComponent, + type TabItem, + TabbedNavComponent, +} from '../../shared/ui'; +import { + buildContextRouteParams, + readContextRouteParam, + readContextRouteState, +} from '../../shared/ui/context-route-state/context-route-state'; type ViewMode = 'list' | 'edit' | 'alerts'; type WatchlistTab = 'entries' | 'alerts' | 'tuning'; @@ -62,7 +73,13 @@ const ALERT_SORT_ORDERS: readonly AlertSortOrder[] = ['newest', 'oldest']; @Component({ selector: 'app-watchlist-page', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [ + CommonModule, + ReactiveFormsModule, + ContextHeaderComponent, + ListDetailShellComponent, + TabbedNavComponent, + ], templateUrl: './watchlist-page.component.html', styleUrls: ['./watchlist-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -118,6 +135,11 @@ export class WatchlistPageComponent implements OnInit { 'Critical', ]; readonly tabOptions: readonly WatchlistTab[] = WATCHLIST_TABS; + readonly tabItems: readonly TabItem[] = [ + { id: 'entries', label: 'Entries', testId: 'watchlist-tab-entries' }, + { id: 'alerts', label: 'Alerts', testId: 'watchlist-tab-alerts' }, + { id: 'tuning', label: 'Tuning', testId: 'watchlist-tab-tuning' }, + ]; readonly scopeOptions: readonly WatchlistScopeFilter[] = WATCHLIST_SCOPES; readonly alertWindows: readonly AlertWindow[] = ALERT_WINDOWS; readonly alertSortOptions: readonly AlertSortOrder[] = ALERT_SORT_ORDERS; @@ -729,6 +751,21 @@ export class WatchlistPageComponent implements OnInit { void this.loadEntries(); } + onTabSelected(tabId: string): void { + switch (tabId as WatchlistTab) { + case 'alerts': + this.showAlerts(); + break; + case 'tuning': + this.showTuning(); + break; + case 'entries': + default: + this.showList(); + break; + } + } + changeScope(scope: WatchlistScopeFilter): void { const entry = this.selectedEntry(); const alert = this.selectedAlert(); @@ -886,15 +923,15 @@ export class WatchlistPageComponent implements OnInit { segments: readonly string[], params: ParamMap ): void { - const tab = this.parseTab(segments, params.get('tab')); - const scope = this.parseScope(params.get('scope')); - const entryId = params.get('entryId'); - const duplicateOf = params.get('duplicateOf'); - const alertId = params.get('alertId'); + const tab = this.parseTab(segments, params); + const scope = readContextRouteState(params, 'scope', WATCHLIST_SCOPES, 'tenant'); + const entryId = readContextRouteParam(params, 'entryId'); + const duplicateOf = readContextRouteParam(params, 'duplicateOf'); + const alertId = readContextRouteParam(params, 'alertId'); this.activeTab.set(tab); this.scopeFilter.set(scope); - this.returnTo.set(params.get('returnTo')); + this.returnTo.set(readContextRouteParam(params, 'returnTo')); if (tab === 'alerts') { this.entryPanelMode.set(null); @@ -1033,25 +1070,14 @@ export class WatchlistPageComponent implements OnInit { private parseTab( segments: readonly string[], - queryValue: string | null + params: ParamMap ): WatchlistTab { - if (queryValue && WATCHLIST_TABS.includes(queryValue as WatchlistTab)) { - return queryValue as WatchlistTab; - } - const finalSegment = segments.at(-1); if (finalSegment && WATCHLIST_TABS.includes(finalSegment as WatchlistTab)) { return finalSegment as WatchlistTab; } - return 'entries'; - } - - private parseScope(rawValue: string | null): WatchlistScopeFilter { - if (rawValue && WATCHLIST_SCOPES.includes(rawValue as WatchlistScopeFilter)) { - return rawValue as WatchlistScopeFilter; - } - return 'tenant'; + return readContextRouteState(params, 'tab', WATCHLIST_TABS, 'entries'); } private resolveAlertThreshold(window: AlertWindow): number { @@ -1112,29 +1138,14 @@ export class WatchlistPageComponent implements OnInit { const alertId = overrides.alertId ?? this.selectedAlertId(); const duplicateOf = overrides.duplicateOf ?? this.duplicateSourceId(); - const params: Record = { + return buildContextRouteParams({ scope, tab, - }; - - if (returnTo) { - params['returnTo'] = returnTo; - } - - if (tab === 'entries' && entryId) { - params['entryId'] = entryId; - } - if (tab === 'entries' && entryId === 'new' && duplicateOf) { - params['duplicateOf'] = duplicateOf; - } - if (tab === 'alerts' && alertId) { - params['alertId'] = alertId; - } - if (tab === 'tuning' && entryId) { - params['entryId'] = entryId; - } - - return params; + returnTo, + entryId: tab === 'entries' || tab === 'tuning' ? entryId : null, + duplicateOf: tab === 'entries' && entryId === 'new' ? duplicateOf : null, + alertId: tab === 'alerts' ? alertId : null, + }) as Record; } private showSuccess(message: string): void { diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html index b736089f3..fde9b6cd0 100644 --- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html @@ -1,44 +1,20 @@
-
-
- Back to release runs -

{{ context()?.detail?.releaseName || 'Release Run' }}

-

- Runtime graphing, replay, and evidence for - {{ runId() }} -

-
+ + Back to release runs + - @if (context(); as context) { -
- {{ context.detail.releaseType }} - {{ context.detail.status }} - {{ context.detail.outcome }} - {{ context.detail.targetRegion || 'global' }}/{{ context.detail.targetEnvironment || 'all' }} -
- } -
- - @if (returnTo()) { -
- Opened from another operator flow. - Return to previous context -
- } - - + @if (loading()) {
Loading run graph and replay context...
@@ -241,22 +217,24 @@ } - @if (selectedStep()) { - - } + + +
} diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts index f253b0511..72b7db3f7 100644 --- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts @@ -4,6 +4,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ReplayControlsComponent } from '../evidence-export/replay-controls.component'; +import { + ContextDrawerHostComponent, + ContextHeaderComponent, + type TabItem, + TabbedNavComponent, +} from '../../shared/ui'; +import { + buildContextReturnTo, + buildContextRouteParams, + readContextRouteParam, +} from '../../shared/ui/context-route-state/context-route-state'; import { StepDetailPanelComponent } from './components/step-detail-panel/step-detail-panel.component'; import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component'; import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component'; @@ -20,6 +31,9 @@ import { imports: [ CommonModule, RouterLink, + ContextHeaderComponent, + ContextDrawerHostComponent, + TabbedNavComponent, WorkflowVisualizerComponent, StepDetailPanelComponent, TimeTravelControlsComponent, @@ -45,14 +59,27 @@ export class RunGraphReplayPageComponent { readonly criticalPathOnly = signal(false); readonly returnTo = signal(null); - readonly tabs: readonly { id: RunWorkspaceTab; label: string }[] = [ - { id: 'summary', label: 'Summary' }, - { id: 'graph', label: 'Graph' }, - { id: 'timeline', label: 'Timeline' }, - { id: 'critical-path', label: 'Critical Path' }, - { id: 'replay', label: 'Replay' }, - { id: 'evidence', label: 'Evidence' }, + readonly tabs: readonly TabItem[] = [ + { id: 'summary', label: 'Summary', testId: 'run-workspace-tab-summary' }, + { id: 'graph', label: 'Graph', testId: 'run-workspace-tab-graph' }, + { id: 'timeline', label: 'Timeline', testId: 'run-workspace-tab-timeline' }, + { id: 'critical-path', label: 'Critical Path', testId: 'run-workspace-tab-critical-path' }, + { id: 'replay', label: 'Replay', testId: 'run-workspace-tab-replay' }, + { id: 'evidence', label: 'Evidence', testId: 'run-workspace-tab-evidence' }, ]; + readonly headerChips = computed(() => { + const detail = this.context()?.detail; + if (!detail) { + return []; + } + + return [ + detail.releaseType, + detail.status, + detail.outcome, + `${detail.targetRegion || 'global'}/${detail.targetEnvironment || 'all'}`, + ]; + }); readonly filteredGraph = computed(() => { const context = this.context(); @@ -133,27 +160,32 @@ export class RunGraphReplayPageComponent { }); this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => { - this.returnTo.set(params.get('returnTo')); - this.selectedStepId.set(params.get('step')); + this.returnTo.set(readContextRouteParam(params, 'returnTo')); + this.selectedStepId.set(readContextRouteParam(params, 'step')); }); } setTab(tab: RunWorkspaceTab): void { void this.router.navigate(['/releases/runs', this.runId(), tab], { - queryParams: { + queryParams: buildContextRouteParams({ + drawer: this.selectedStepId() ? 'step' : null, step: this.selectedStepId(), returnTo: this.returnTo(), - }, + }), queryParamsHandling: 'merge', replaceUrl: true, }); } + onTabSelected(tabId: string): void { + this.setTab(this.normalizeTab(tabId)); + } + openStep(stepId: string): void { this.selectedStepId.set(stepId); void this.router.navigate([], { relativeTo: this.route, - queryParams: { step: stepId }, + queryParams: buildContextRouteParams({ drawer: 'step', step: stepId }), queryParamsHandling: 'merge', replaceUrl: true, }); @@ -163,7 +195,7 @@ export class RunGraphReplayPageComponent { this.selectedStepId.set(null); void this.router.navigate([], { relativeTo: this.route, - queryParams: { step: null }, + queryParams: buildContextRouteParams({ drawer: null, step: null }), queryParamsHandling: 'merge', replaceUrl: true, }); @@ -183,6 +215,14 @@ export class RunGraphReplayPageComponent { queryParams: { releaseId: context?.detail.releaseId, runId: this.runId(), + returnTo: buildContextReturnTo( + this.router, + ['/releases/runs', this.runId(), this.activeTab()], + { + drawer: this.selectedStepId() ? 'step' : null, + step: this.selectedStepId(), + }, + ), }, }); } @@ -201,6 +241,15 @@ export class RunGraphReplayPageComponent { } } + returnToSource(): void { + const returnTo = this.returnTo(); + if (!returnTo) { + return; + } + + void this.router.navigateByUrl(returnTo).catch(() => undefined); + } + formatWhen(value: string | null | undefined): string { if (!value) { return 'n/a'; diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts new file mode 100644 index 000000000..87d91115e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts @@ -0,0 +1,243 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + HostListener, + Input, + Output, +} from '@angular/core'; + +let nextContextDrawerId = 0; + +@Component({ + selector: 'app-context-drawer-host', + standalone: true, + template: ` + @if (open) { +
+ @if (presentation === 'overlay' && showBackdrop) { + + } + +
+
+
+ @if (eyebrow) { +

{{ eyebrow }}

+ } +

{{ title }}

+ @if (description) { +

{{ description }}

+ } +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ } + `, + styles: [` + .context-drawer-host { + min-width: 0; + } + + .context-drawer-host--overlay { + inset: 0; + pointer-events: none; + position: fixed; + z-index: 40; + } + + .context-drawer-host--rail { + display: block; + height: 100%; + min-width: 0; + } + + .context-drawer-host__backdrop { + background: color-mix(in srgb, #09101d 58%, transparent); + border: none; + inset: 0; + pointer-events: auto; + position: absolute; + } + + .context-drawer-host__panel { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + box-shadow: 0 24px 48px rgba(4, 16, 32, 0.22); + min-width: 0; + pointer-events: auto; + } + + .context-drawer-host--overlay .context-drawer-host__panel { + border-bottom-left-radius: 1rem; + border-top-left-radius: 1rem; + height: 100%; + margin-left: auto; + overflow: auto; + position: relative; + } + + .context-drawer-host--rail .context-drawer-host__panel { + border-radius: 1rem; + height: 100%; + overflow: auto; + position: sticky; + top: 1rem; + } + + .context-drawer-host__panel--md { + width: min(28rem, 92vw); + } + + .context-drawer-host__panel--lg { + width: min(36rem, 94vw); + } + + .context-drawer-host__panel--xl { + width: min(46rem, 96vw); + } + + .context-drawer-host--rail .context-drawer-host__panel--md, + .context-drawer-host--rail .context-drawer-host__panel--lg, + .context-drawer-host--rail .context-drawer-host__panel--xl { + width: 100%; + } + + .context-drawer-host__header { + align-items: flex-start; + border-bottom: 1px solid var(--color-border-primary); + display: flex; + gap: 1rem; + justify-content: space-between; + padding: 1rem 1rem 0.85rem; + } + + .context-drawer-host__title-block { + display: grid; + gap: 0.35rem; + min-width: 0; + } + + .context-drawer-host__eyebrow { + margin: 0; + color: var(--color-status-info, var(--color-brand-primary)); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .context-drawer-host__title { + margin: 0; + font-size: 1.05rem; + } + + .context-drawer-host__description { + color: var(--color-text-secondary); + margin: 0; + line-height: 1.45; + } + + .context-drawer-host__header-actions { + align-items: center; + display: flex; + gap: 0.65rem; + justify-content: flex-end; + flex-shrink: 0; + } + + .context-drawer-host__close { + background: var(--color-surface-secondary, var(--color-surface-primary)); + border: 1px solid var(--color-border-primary); + border-radius: 0.75rem; + color: var(--color-text-primary); + cursor: pointer; + padding: 0.55rem 0.85rem; + } + + .context-drawer-host__body { + display: grid; + gap: 1rem; + padding: 1rem; + } + + @media (max-width: 900px) { + .context-drawer-host__header { + display: grid; + } + + .context-drawer-host__header-actions { + justify-content: flex-start; + } + + .context-drawer-host--rail .context-drawer-host__panel { + position: static; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContextDrawerHostComponent { + @Input() open = false; + @Input() presentation: 'overlay' | 'rail' = 'overlay'; + @Input() size: 'md' | 'lg' | 'xl' = 'lg'; + @Input() eyebrow = ''; + @Input() title = ''; + @Input() description = ''; + @Input() closeLabel = 'Close'; + @Input() showBackdrop = true; + @Input() testId: string | null = null; + @Input() closeTestId: string | null = null; + + @Output() readonly closed = new EventEmitter(); + + readonly titleId = `context-drawer-title-${nextContextDrawerId++}`; + + @HostListener('document:keydown.escape') + onEscape(): void { + if (this.open) { + this.requestClose(); + } + } + + requestClose(): void { + this.closed.emit(); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts new file mode 100644 index 000000000..c560a8909 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts @@ -0,0 +1,153 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-context-header', + standalone: true, + template: ` +
+
+ @if (eyebrow) { +

{{ eyebrow }}

+ } + +
+

{{ title }}

+ + @if (chips.length) { +
+ @for (chip of chips; track chip) { + {{ chip }} + } +
+ } +
+ + @if (subtitle) { +

{{ subtitle }}

+ } + + @if (contextNote) { +

{{ contextNote }}

+ } +
+ +
+ @if (backLabel) { + + } + + +
+
+ `, + styles: [` + .context-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + } + + .context-header__copy { + display: grid; + gap: 0.35rem; + min-width: 0; + } + + .context-header__eyebrow { + margin: 0; + color: var(--color-status-info, var(--color-brand-primary)); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .context-header__title-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + } + + .context-header__title { + margin: 0; + color: var(--color-text-heading, var(--color-text-primary)); + font-size: 1.6rem; + } + + .context-header__subtitle, + .context-header__note { + margin: 0; + color: var(--color-text-secondary); + line-height: 1.45; + } + + .context-header__note { + font-size: 0.92rem; + } + + .context-header__chips { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .context-header__chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid var(--color-border-primary); + padding: 0.2rem 0.55rem; + background: var(--color-surface-secondary, var(--color-surface-primary)); + color: var(--color-text-secondary); + font-size: 0.76rem; + } + + .context-header__actions { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex-wrap: wrap; + flex-shrink: 0; + } + + .context-header__return { + border: 1px solid var(--color-border-primary); + border-radius: 0.75rem; + background: var(--color-surface-secondary, var(--color-surface-primary)); + color: var(--color-text-primary); + cursor: pointer; + font-weight: 600; + padding: 0.6rem 0.9rem; + } + + @media (max-width: 860px) { + .context-header { + display: grid; + } + + .context-header__actions { + justify-content: flex-start; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContextHeaderComponent { + @Input() eyebrow = ''; + @Input() title = ''; + @Input() subtitle = ''; + @Input() contextNote = ''; + @Input() chips: readonly string[] = []; + @Input() backLabel: string | null = null; + + @Output() readonly backClick = new EventEmitter(); +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts b/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts new file mode 100644 index 000000000..00cd3667e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts @@ -0,0 +1,81 @@ +import type { Params, Router } from '@angular/router'; + +export type ContextRouteStateKey = + | 'tab' + | 'panel' + | 'drawer' + | 'returnTo' + | 'scope' + | 'view'; + +export interface ContextRouteStateReader { + get(name: string): string | null; +} + +export function coerceContextRouteState( + value: string | null | undefined, + allowed: readonly T[], + fallback: T, +): T { + if (!value) { + return fallback; + } + + return allowed.includes(value as T) ? (value as T) : fallback; +} + +export function readContextRouteState( + reader: ContextRouteStateReader, + key: ContextRouteStateKey | string, + allowed: readonly T[], + fallback: T, +): T { + return coerceContextRouteState(reader.get(key), allowed, fallback); +} + +export function readContextRouteParam( + reader: ContextRouteStateReader, + key: ContextRouteStateKey | string, +): string | null { + const value = reader.get(key); + if (!value) { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildContextRouteParams( + values: Record, +): Params { + const params: Params = {}; + + for (const [key, value] of Object.entries(values)) { + if (value === null) { + params[key] = null; + continue; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length > 0) { + params[key] = trimmed; + } + } + } + + return params; +} + +export function buildContextReturnTo( + router: Router, + commands: readonly unknown[], + queryParams?: Record, +): string { + return router.serializeUrl( + router.createUrlTree(commands as readonly string[], { + queryParams: queryParams ? buildContextRouteParams(queryParams) : undefined, + }), + ); +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts index 4a848fab5..6ab5bf3ca 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/index.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/index.ts @@ -7,9 +7,14 @@ // Layout primitives export * from './page-header/page-header.component'; +export * from './context-header/context-header.component'; +export * from './context-drawer-host/context-drawer-host.component'; export * from './filter-bar/filter-bar.component'; +export * from './list-detail-shell/list-detail-shell.component'; export * from './split-pane/split-pane.component'; export * from './tabbed-nav/tabbed-nav.component'; +export * from './overview-card-groups/overview-card-groups.component'; +export * from './context-route-state/context-route-state'; // Data display export * from './status-badge/status-badge.component'; diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts new file mode 100644 index 000000000..f3fe1756c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-list-detail-shell', + standalone: true, + template: ` +
+
+ +
+ + @if (detailVisible) { +
+ +
+ } +
+ `, + styles: [` + .list-detail-shell { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr); + } + + .list-detail-shell--with-detail { + grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem)); + align-items: start; + } + + .list-detail-shell__primary, + .list-detail-shell__detail { + min-width: 0; + } + + @media (max-width: 1100px) { + .list-detail-shell, + .list-detail-shell--with-detail { + grid-template-columns: minmax(0, 1fr); + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ListDetailShellComponent { + @Input() detailVisible = false; + @Input() detailWidth = '24rem'; +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts new file mode 100644 index 000000000..05d1a1756 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts @@ -0,0 +1,161 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +export type OverviewCardImpact = 'blocking' | 'degraded' | 'info'; + +export interface OverviewCardGroupItem { + readonly id: string; + readonly title: string; + readonly detail: string; + readonly metric: string; + readonly impact: OverviewCardImpact; + readonly route: string; + readonly owner?: string; +} + +export interface OverviewCardGroup { + readonly id: string; + readonly title: string; + readonly description: string; + readonly cards: readonly OverviewCardGroupItem[]; +} + +@Component({ + selector: 'app-overview-card-groups', + standalone: true, + imports: [RouterLink], + template: ` + @for (group of groups; track group.id) { +
+
+
+

{{ group.title }}

+

{{ group.description }}

+
+
+ + +
+ } + `, + styles: [` + .overview-card-groups__group { + display: grid; + gap: 0.9rem; + } + + .overview-card-groups__header h2, + .overview-card-groups__card h3 { + margin: 0; + } + + .overview-card-groups__header p, + .overview-card-groups__card p { + color: var(--color-text-secondary); + margin: 0.35rem 0 0; + line-height: 1.45; + } + + .overview-card-groups__grid { + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .overview-card-groups__card { + display: grid; + gap: 0.8rem; + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: 1rem; + background: var(--color-surface-primary); + color: inherit; + text-decoration: none; + min-width: 0; + transition: border-color 120ms ease, transform 120ms ease; + } + + .overview-card-groups__card:hover { + border-color: var(--color-brand-primary); + transform: translateY(-1px); + } + + .overview-card-groups__card-topline { + align-items: center; + display: flex; + gap: 0.5rem; + justify-content: space-between; + } + + .overview-card-groups__owner, + .overview-card-groups__impact { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.18rem 0.55rem; + font-size: 0.76rem; + font-weight: 600; + } + + .overview-card-groups__owner { + background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent); + color: var(--color-brand-primary); + } + + .overview-card-groups__owner--setup { + background: color-mix(in srgb, var(--color-status-warning-bg) 72%, transparent); + color: var(--color-status-warning-text); + } + + .overview-card-groups__impact { + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + } + + .overview-card-groups__impact--blocking { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + } + + .overview-card-groups__impact--degraded { + background: var(--color-status-warning-bg); + color: var(--color-status-warning-text); + } + + .overview-card-groups__impact--info { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OverviewCardGroupsComponent { + @Input() groups: readonly OverviewCardGroup[] = []; + @Input() groupTestIdPrefix = 'overview-group'; + @Input() cardTestIdPrefix = 'overview-card'; +} diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts index f92e1bab2..69d6cfcde 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts @@ -13,8 +13,10 @@ export interface TabItem { id: string; label: string; icon?: string; - route?: string; // If set, uses router navigation + route?: string | readonly unknown[]; // If set, uses router navigation + queryParams?: Record; disabled?: boolean; + testId?: string; } @Component({ @@ -23,16 +25,18 @@ export interface TabItem { imports: [RouterLink, RouterLinkActive], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -