feat(web): derive page-header into canonical context-header with unified header contract [SPRINT-027]

Enhance ContextHeaderComponent to be the single canonical header primitive:
- Add configurable heading level (h1/h2/h3) for semantic HTML in nested shells
- Add testId input for Playwright targeting (data-testid)
- Add ARIA labels on return button and chip list (role=list/listitem)
- Add back-arrow indicator for improved return-button affordance
- Add JSDoc on all inputs for developer ergonomics

Deprecate PageHeaderComponent to a thin compatibility wrapper that delegates
to ContextHeaderComponent.

Adopt canonical header on 4 representative pages:
- RegistryAdminComponent (admin/setup surface)
- PackRegistryBrowserComponent (operational surface)
- DeadLetterDashboardComponent (operational surface)
- OfflineKitComponent (operational surface)

Each adopted page gains eyebrow breadcrumb context, consistent subtitle
placement, and projected actions via the shared header-actions slot,
replacing ~80 lines of repeated ad-hoc header markup.

15 focused component tests covering title rendering, eyebrow/subtitle
display, chips with ARIA, back action, action slot projection, heading
levels, testId, and responsive layout structure. All pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 23:19:21 +02:00
parent d7f55b72c8
commit 12a6ef831b
11 changed files with 402 additions and 215 deletions

View File

@@ -1,4 +1,5 @@
// Sprint: SPRINT_20251229_030_FE - Dead-Letter Management UI
// Sprint 027: Adopted canonical ContextHeaderComponent
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { RouterModule } from '@angular/router';
@@ -14,18 +15,20 @@ import {
BatchReplayProgress,
ERROR_CODE_REFERENCES,
} from '../../core/api/deadletter.models';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
@Component({
selector: 'app-deadletter-dashboard',
imports: [RouterModule, FormsModule],
imports: [RouterModule, FormsModule, ContextHeaderComponent],
template: `
<div class="deadletter-dashboard">
<header class="page-header">
<div class="header-content">
<h1>Dead-Letter Queue Management</h1>
<p class="subtitle">Failed job recovery and diagnostics</p>
</div>
<div class="header-actions">
<app-context-header
eyebrow="Ops / Execution"
title="Dead-Letter Queue Management"
subtitle="Failed job recovery and diagnostics"
testId="deadletter-dashboard-header"
>
<div header-actions class="header-actions">
<button class="btn btn-secondary" (click)="exportData()">
<span class="icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg></span>
Export CSV
@@ -42,7 +45,7 @@ import {
<span class="icon" [class.spinning]="refreshing()" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg></span>
</button>
</div>
</header>
</app-context-header>
<!-- Batch Progress Banner -->
@if (batchProgress()) {
@@ -410,23 +413,6 @@ import {
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.75rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
}
.header-actions {
display: flex;
gap: 0.5rem;

View File

@@ -1,35 +1,40 @@
// Offline Kit Component
// Sprint 026: Offline Kit Integration
// Sprint 027: Adopted canonical ContextHeaderComponent
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { OfflineModeService } from '../../core/services/offline-mode.service';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
@Component({
selector: 'app-offline-kit',
imports: [RouterModule],
imports: [RouterModule, ContextHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="offline-kit-layout">
<header class="page-header">
<div class="header-content">
<h1>Offline Kit Management</h1>
<p class="subtitle">Manage offline bundles, verify audit packages, and configure air-gap operation</p>
<div class="page-shortcuts">
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
<a routerLink="/evidence/exports">Evidence Exports</a>
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
<a routerLink="/setup/trust-signing">Trust & Signing</a>
</div>
</div>
<div class="header-status">
<app-context-header
eyebrow="Ops / Feeds & Airgap"
title="Offline Kit Management"
subtitle="Manage offline bundles, verify audit packages, and configure air-gap operation"
[chips]="[isOffline() ? 'Offline' : 'Online']"
testId="offline-kit-header"
>
<div header-actions class="header-status">
<div class="connection-status" [class.offline]="isOffline()">
<span class="status-dot"></span>
<span class="status-text">{{ isOffline() ? 'Offline' : 'Online' }}</span>
</div>
</div>
</header>
</app-context-header>
<div class="page-shortcuts">
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
<a routerLink="/evidence/exports">Evidence Exports</a>
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
<a routerLink="/setup/trust-signing">Trust & Signing</a>
</div>
<nav class="tab-nav">
<a routerLink="dashboard" routerLinkActive="active" class="tab-link">
@@ -76,28 +81,8 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
background: var(--color-surface-primary);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--color-border-primary);
}
.header-content h1 {
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
margin: 0 0 0.25rem;
}
.subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0;
}
.page-shortcuts {
padding: 0 2rem;
display: flex;
gap: 0.45rem;
flex-wrap: wrap;

View File

@@ -9,23 +9,25 @@ import {
PackVersionRow,
} from './models/pack-registry-browser.models';
import { PackRegistryBrowserService } from './services/pack-registry-browser.service';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
@Component({
selector: 'app-pack-registry-browser',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ContextHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="pack-registry-page">
<header class="page-header">
<div>
<h1>Pack Registry Browser</h1>
<p>Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades.</p>
</div>
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
<app-context-header
eyebrow="Ops / Execution"
title="Pack Registry Browser"
subtitle="Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades."
testId="pack-registry-header"
>
<button header-actions type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
</app-context-header>
<section class="kpi-grid" aria-label="Pack registry summary">
<article class="kpi-card">
@@ -209,24 +211,6 @@ import { PackRegistryBrowserService } from './services/pack-registry-browser.ser
gap: 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
h1 {
margin: 0;
font-size: 1.6rem;
line-height: 1.2;
}
.page-header p {
margin: 0.4rem 0 0;
color: var(--color-text-secondary);
}
.refresh-btn {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);

View File

@@ -1,5 +1,6 @@
// Registry Admin Component
// Sprint 023: Registry Admin UI
// Sprint 027: Adopted canonical ContextHeaderComponent
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
@@ -11,38 +12,37 @@ import {
RegistryAdminHttpService,
} from '../../core/api/registry-admin.client';
import { PlanRuleDto } from '../../core/api/registry-admin.models';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
type TabType = 'plans' | 'audit';
@Component({
selector: 'app-registry-admin',
imports: [RouterModule],
imports: [RouterModule, ContextHeaderComponent],
providers: [
{ provide: REGISTRY_ADMIN_API, useClass: RegistryAdminHttpService },
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="registry-admin">
<header class="registry-admin__header">
<div class="registry-admin__title-row">
<div>
<h1 class="registry-admin__title">Registry Token Service</h1>
<p class="registry-admin__subtitle">
Manage access plans, repository scopes, and allowlists
</p>
<app-context-header
eyebrow="Setup / Integrations"
title="Registry Token Service"
subtitle="Manage access plans, repository scopes, and allowlists"
[chips]="headerChips()"
testId="registry-admin-header"
>
<div header-actions class="registry-admin__stats">
<div class="stat-card">
<span class="stat-value">{{ totalPlans() }}</span>
<span class="stat-label">Plans</span>
</div>
<div class="registry-admin__stats">
<div class="stat-card">
<span class="stat-value">{{ totalPlans() }}</span>
<span class="stat-label">Plans</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ enabledPlans() }}</span>
<span class="stat-label">Enabled</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ enabledPlans() }}</span>
<span class="stat-label">Enabled</span>
</div>
</div>
</header>
</app-context-header>
<nav class="registry-admin__tabs" role="tablist">
<a
@@ -90,30 +90,6 @@ type TabType = 'plans' | 'audit';
padding: 1.5rem;
}
.registry-admin__header {
margin-bottom: 1.5rem;
}
.registry-admin__title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.registry-admin__title {
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
margin: 0 0 0.25rem;
color: var(--color-surface-secondary);
}
.registry-admin__subtitle {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.registry-admin__stats {
display: flex;
gap: 1rem;
@@ -199,6 +175,14 @@ export class RegistryAdminComponent implements OnInit {
readonly totalPlans = computed(() => this.plans().length);
readonly enabledPlans = computed(() => this.plans().filter((p) => p.enabled).length);
readonly headerChips = computed(() => {
const total = this.totalPlans();
const enabled = this.enabledPlans();
if (!total) {
return [];
}
return [`${enabled}/${total} enabled`];
});
ngOnInit(): void {
this.loadDashboard();

View File

@@ -0,0 +1,208 @@
/**
* Context Header Component Tests
* Sprint 027: Canonical header contract verification
*/
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContextHeaderComponent, HeadingLevel } from './context-header.component';
/* ------------------------------------------------------------------ */
/* Test host: exercises all inputs, output, and content projection */
/* ------------------------------------------------------------------ */
@Component({
standalone: true,
imports: [ContextHeaderComponent],
template: `
<app-context-header
[eyebrow]="eyebrow"
[title]="title"
[subtitle]="subtitle"
[contextNote]="contextNote"
[chips]="chips"
[backLabel]="backLabel"
[headingLevel]="headingLevel"
[testId]="testId"
(backClick)="backClicked = true"
>
<button header-actions data-testid="projected-action">Do Something</button>
</app-context-header>
`,
})
class ContextHeaderTestHostComponent {
eyebrow = '';
title = 'Test Page';
subtitle = '';
contextNote = '';
chips: readonly string[] = [];
backLabel: string | null = null;
headingLevel: HeadingLevel = 1;
testId: string | null = null;
backClicked = false;
}
describe('ContextHeaderComponent', () => {
let fixture: ComponentFixture<ContextHeaderTestHostComponent>;
let host: ContextHeaderTestHostComponent;
let el: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContextHeaderTestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(ContextHeaderTestHostComponent);
host = fixture.componentInstance;
el = fixture.nativeElement;
fixture.detectChanges();
});
/* ---- Title rendering ---- */
it('renders the title as an h1 by default', () => {
const h1 = el.querySelector('h1');
expect(h1).toBeTruthy();
expect(h1!.textContent!.trim()).toBe('Test Page');
});
it('does not render eyebrow, subtitle, or note when empty', () => {
expect(el.querySelector('.context-header__eyebrow')).toBeFalsy();
expect(el.querySelector('.context-header__subtitle')).toBeFalsy();
expect(el.querySelector('.context-header__note')).toBeFalsy();
});
/* ---- Eyebrow/subtitle display ---- */
it('renders eyebrow text when provided', () => {
host.eyebrow = 'Ops / Policy';
fixture.detectChanges();
const eyebrow = el.querySelector('.context-header__eyebrow');
expect(eyebrow).toBeTruthy();
expect(eyebrow!.textContent!.trim()).toBe('Ops / Policy');
});
it('renders subtitle and context note when provided', () => {
host.subtitle = 'A brief description';
host.contextNote = 'Additional operational context';
fixture.detectChanges();
expect(el.querySelector('.context-header__subtitle')!.textContent!.trim()).toBe('A brief description');
expect(el.querySelector('.context-header__note')!.textContent!.trim()).toBe('Additional operational context');
});
/* ---- Chips ---- */
it('renders chips when provided', () => {
host.chips = ['running', 'prod', 'v2.1'];
fixture.detectChanges();
const chips = el.querySelectorAll('.context-header__chip');
expect(chips.length).toBe(3);
expect(chips[0].textContent!.trim()).toBe('running');
expect(chips[1].textContent!.trim()).toBe('prod');
expect(chips[2].textContent!.trim()).toBe('v2.1');
});
it('does not render chips container when empty', () => {
host.chips = [];
fixture.detectChanges();
expect(el.querySelector('.context-header__chips')).toBeFalsy();
});
it('marks chip container with role=list for accessibility', () => {
host.chips = ['status'];
fixture.detectChanges();
const container = el.querySelector('.context-header__chips');
expect(container!.getAttribute('role')).toBe('list');
expect(container!.getAttribute('aria-label')).toBe('Context chips');
const chip = el.querySelector('.context-header__chip');
expect(chip!.getAttribute('role')).toBe('listitem');
});
/* ---- Back action behavior ---- */
it('hides back button when backLabel is null', () => {
host.backLabel = null;
fixture.detectChanges();
expect(el.querySelector('.context-header__return')).toBeFalsy();
});
it('renders back button and emits backClick when clicked', () => {
host.backLabel = 'Return to Findings';
fixture.detectChanges();
const button = el.querySelector('.context-header__return') as HTMLButtonElement;
expect(button).toBeTruthy();
expect(button.textContent).toContain('Return to Findings');
expect(button.getAttribute('aria-label')).toBe('Navigate back: Return to Findings');
button.click();
fixture.detectChanges();
expect(host.backClicked).toBeTrue();
});
/* ---- Action slot projection ---- */
it('projects content into the header-actions slot', () => {
const projected = el.querySelector('[data-testid="projected-action"]');
expect(projected).toBeTruthy();
expect(projected!.textContent!.trim()).toBe('Do Something');
});
/* ---- Heading levels (accessibility) ---- */
it('renders h2 when headingLevel is 2', () => {
host.headingLevel = 2;
fixture.detectChanges();
expect(el.querySelector('h1')).toBeFalsy();
expect(el.querySelector('h2')).toBeTruthy();
expect(el.querySelector('h2')!.textContent!.trim()).toBe('Test Page');
});
it('renders h3 when headingLevel is 3', () => {
host.headingLevel = 3;
fixture.detectChanges();
expect(el.querySelector('h1')).toBeFalsy();
expect(el.querySelector('h3')).toBeTruthy();
expect(el.querySelector('h3')!.textContent!.trim()).toBe('Test Page');
});
/* ---- Test ID ---- */
it('sets data-testid on the header element when provided', () => {
host.testId = 'my-page-header';
fixture.detectChanges();
const header = el.querySelector('header');
expect(header!.getAttribute('data-testid')).toBe('my-page-header');
});
it('does not set data-testid when testId is null', () => {
host.testId = null;
fixture.detectChanges();
const header = el.querySelector('header');
expect(header!.getAttribute('data-testid')).toBeNull();
});
/* ---- Responsive behavior (structural check) ---- */
it('renders the header with flex layout between copy and actions', () => {
const header = el.querySelector('.context-header') as HTMLElement;
expect(header).toBeTruthy();
const copy = el.querySelector('.context-header__copy');
const actions = el.querySelector('.context-header__actions');
expect(copy).toBeTruthy();
expect(actions).toBeTruthy();
});
});

View File

@@ -1,22 +1,47 @@
/**
* Context Header Component
*
* Canonical page header primitive for Stella Ops. Serves both simple
* admin/settings pages (title + subtitle + actions) and richer operational
* pages (eyebrow, chips, back action, context note).
*
* Replaces the deprecated PageHeaderComponent (SPRINT-027).
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
/** Allowed heading element levels for the title. */
export type HeadingLevel = 1 | 2 | 3;
@Component({
selector: 'app-context-header',
standalone: true,
template: `
<header class="context-header">
<header
class="context-header"
[attr.data-testid]="testId"
>
<div class="context-header__copy">
@if (eyebrow) {
<p class="context-header__eyebrow">{{ eyebrow }}</p>
}
<div class="context-header__title-row">
<h1 class="context-header__title">{{ title }}</h1>
@switch (headingLevel) {
@case (2) {
<h2 class="context-header__title">{{ title }}</h2>
}
@case (3) {
<h3 class="context-header__title">{{ title }}</h3>
}
@default {
<h1 class="context-header__title">{{ title }}</h1>
}
}
@if (chips.length) {
<div class="context-header__chips" aria-label="Context chips">
<div class="context-header__chips" role="list" aria-label="Context chips">
@for (chip of chips; track chip) {
<span class="context-header__chip">{{ chip }}</span>
<span class="context-header__chip" role="listitem">{{ chip }}</span>
}
</div>
}
@@ -36,8 +61,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
<button
type="button"
class="context-header__return"
[attr.aria-label]="'Navigate back: ' + backLabel"
(click)="backClick.emit()"
>
<span class="context-header__return-arrow" aria-hidden="true">&larr;</span>
{{ backLabel }}
</button>
}
@@ -80,6 +107,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
margin: 0;
color: var(--color-text-heading, var(--color-text-primary));
font-size: 1.6rem;
font-weight: var(--font-weight-semibold, 600);
}
.context-header__subtitle,
@@ -89,6 +117,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
line-height: 1.45;
}
.context-header__subtitle {
font-size: 0.875rem;
}
.context-header__note {
font-size: 0.92rem;
}
@@ -120,6 +152,9 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
}
.context-header__return {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--color-border-primary);
border-radius: 0.75rem;
background: var(--color-surface-secondary, var(--color-surface-primary));
@@ -129,6 +164,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
padding: 0.6rem 0.9rem;
}
.context-header__return:hover {
background: var(--color-surface-tertiary, var(--color-surface-secondary));
}
.context-header__return-arrow {
font-size: 1rem;
line-height: 1;
}
@media (max-width: 860px) {
.context-header {
display: grid;
@@ -142,12 +186,36 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContextHeaderComponent {
/** Contextual eyebrow label shown above the title (e.g. breadcrumb path). */
@Input() eyebrow = '';
/** Primary heading text (required for meaningful display). */
@Input() title = '';
/** Short description displayed below the title. */
@Input() subtitle = '';
/** Extended contextual note displayed below the subtitle. */
@Input() contextNote = '';
/** Status or context chips displayed inline with the title. */
@Input() chips: readonly string[] = [];
/**
* Label for the back/return button. When null or empty, the button is hidden.
* Use for contextual navigation (e.g. "Return to Findings").
*/
@Input() backLabel: string | null = null;
/**
* Semantic heading level (1, 2, or 3). Defaults to 1 (h1).
* Use 2 for pages nested inside a shell that already provides an h1.
*/
@Input() headingLevel: HeadingLevel = 1;
/** Optional test identifier for the header element. */
@Input() testId: string | null = null;
/** Emitted when the user clicks the back/return button. */
@Output() readonly backClick = new EventEmitter<void>();
}

View File

@@ -8,6 +8,7 @@
// Layout primitives
export * from './page-header/page-header.component';
export * from './context-header/context-header.component';
/** @deprecated Use ContextHeaderComponent instead */
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';

View File

@@ -1,76 +1,39 @@
/**
* Page Header Component
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
*
* Consistent page header with title, subtitle, and actions.
* @deprecated Use ContextHeaderComponent instead. This component is a
* compatibility wrapper retained for any remaining references. It will
* be removed in a future cleanup sprint.
*
* Migration guide:
* <app-page-header title="T" subtitle="S">
* <button primary-actions>Action</button>
* </app-page-header>
*
* becomes:
*
* <app-context-header title="T" subtitle="S">
* <button header-actions>Action</button>
* </app-context-header>
*/
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { ContextHeaderComponent } from '../context-header/context-header.component';
@Component({
selector: 'app-page-header',
standalone: true,
imports: [],
imports: [ContextHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="page-header">
<div class="page-header__content">
<h1 class="page-header__title">{{ title }}</h1>
@if (subtitle) {
<p class="page-header__subtitle">{{ subtitle }}</p>
}
</div>
<div class="page-header__actions">
<ng-content select="[secondary-actions]"></ng-content>
<ng-content select="[primary-actions]"></ng-content>
</div>
</header>
<app-context-header
[title]="title"
[subtitle]="subtitle"
testId="page-header-compat"
>
<ng-content select="[secondary-actions]" header-actions></ng-content>
<ng-content select="[primary-actions]" header-actions></ng-content>
</app-context-header>
`,
styles: [`
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
margin-bottom: 1.5rem;
}
.page-header__content {
flex: 1;
}
.page-header__title {
margin: 0 0 0.375rem;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.page-header__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.page-header__actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.page-header__actions {
width: 100%;
justify-content: flex-start;
}
}
`]
})
export class PageHeaderComponent {
@Input() title!: string;