initial commit
This commit is contained in:
24
frontend/src/app/layout/bottom-nav/bottom-nav.component.html
Normal file
24
frontend/src/app/layout/bottom-nav/bottom-nav.component.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<nav class="bottom-nav" aria-label="Bottom navigation">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="bottom-nav__item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="bottom-nav__item--active"
|
||||
#rla="routerLinkActive"
|
||||
[attr.aria-label]="dest.label"
|
||||
[attr.aria-current]="rla.isActive ? 'page' : null"
|
||||
>
|
||||
<span class="bottom-nav__icon-wrapper">
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
76
frontend/src/app/layout/bottom-nav/bottom-nav.component.scss
Normal file
76
frontend/src/app/layout/bottom-nav/bottom-nav.component.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
// ============================================================================
|
||||
// Bottom Navigation Bar — Mobile Navigation
|
||||
// Per spec Section 3.2: M3 NavigationBar pattern
|
||||
// Visible only on compact breakpoint (< 600px)
|
||||
// ============================================================================
|
||||
|
||||
.bottom-nav {
|
||||
display: none; // Hidden on desktop, shown on mobile via media query
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--cc-bottom-nav-height);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
color: var(--cc-on-surface-variant);
|
||||
border-radius: 16px;
|
||||
transition: color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--status-active);
|
||||
background-color: var(--status-active-bg);
|
||||
|
||||
.bottom-nav__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
|
||||
.bottom-nav__item--active & {
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Show bottom nav only on compact breakpoint
|
||||
@media (max-width: 599px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
24
frontend/src/app/layout/bottom-nav/bottom-nav.component.ts
Normal file
24
frontend/src/app/layout/bottom-nav/bottom-nav.component.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
/**
|
||||
* Bottom Navigation Bar for mobile (compact breakpoint).
|
||||
* Per spec Section 3.2: M3 NavigationBar, 3–5 destinations,
|
||||
* active destination uses M3 indicator pill.
|
||||
* Visible only on compact (< 600px) breakpoint.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-bottom-nav',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './bottom-nav.component.html',
|
||||
styleUrl: './bottom-nav.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BottomNavComponent {
|
||||
/** Show only first 5 destinations on bottom nav */
|
||||
protected readonly destinations = NAV_DESTINATIONS.slice(0, 5);
|
||||
}
|
||||
45
frontend/src/app/layout/header-bar/header-bar.component.html
Normal file
45
frontend/src/app/layout/header-bar/header-bar.component.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<header class="header-bar" role="banner">
|
||||
<h1 class="header-bar__title">Command Hub</h1>
|
||||
|
||||
<div class="header-bar__actions">
|
||||
<!-- Live indicator -->
|
||||
<button
|
||||
class="header-bar__action-btn header-bar__live-btn"
|
||||
mat-icon-button
|
||||
[attr.aria-label]="isConnected() ? 'Connected — live' : 'Disconnected'"
|
||||
>
|
||||
<span
|
||||
class="header-bar__live-dot"
|
||||
[class.header-bar__live-dot--connected]="isConnected()"
|
||||
></span>
|
||||
<span class="header-bar__live-label">
|
||||
{{ isConnected() ? 'Live' : 'Offline' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="notificationCount()"
|
||||
[matBadgeHidden]="notificationCount() === 0"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
notifications
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Settings -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Settings"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
76
frontend/src/app/layout/header-bar/header-bar.component.scss
Normal file
76
frontend/src/app/layout/header-bar/header-bar.component.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
// ============================================================================
|
||||
// Header Bar — Top App Bar
|
||||
// Per spec Section 3.1: 64px tall, M3 MediumTopAppBar on expanded
|
||||
// Section 3.2: SmallTopAppBar on mobile
|
||||
// ============================================================================
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--cc-header-height);
|
||||
padding: 0 var(--cc-section-padding);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-bar__action-btn {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background-color: var(--status-error);
|
||||
vertical-align: middle;
|
||||
|
||||
&--connected {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface-variant);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Mobile: smaller title
|
||||
@media (max-width: 599px) {
|
||||
.header-bar {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
display: none; // Space saving on mobile — dot alone is enough
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/layout/header-bar/header-bar.component.ts
Normal file
25
frontend/src/app/layout/header-bar/header-bar.component.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
|
||||
/**
|
||||
* Header Bar component for the Command Hub.
|
||||
* Per spec Section 3.1: 64px tall, app title + live indicator + notification bell + settings.
|
||||
* Uses M3 top app bar pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-header-bar',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, MatButtonModule, MatBadgeModule],
|
||||
templateUrl: './header-bar.component.html',
|
||||
styleUrl: './header-bar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HeaderBarComponent {
|
||||
protected readonly notificationCount = signal(3);
|
||||
protected readonly isConnected = signal(true);
|
||||
|
||||
// TODO: Wire up notification panel (spec Section 2: Notifications Panel)
|
||||
// TODO: Wire up settings navigation
|
||||
}
|
||||
4
frontend/src/app/layout/index.ts
Normal file
4
frontend/src/app/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './nav-rail/nav-rail.component';
|
||||
export * from './bottom-nav/bottom-nav.component';
|
||||
export * from './header-bar/header-bar.component';
|
||||
export * from './layout-shell/layout-shell.component';
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="layout-shell">
|
||||
<!-- Desktop/Kiosk: Nav Rail on the left -->
|
||||
<app-nav-rail class="layout-shell__nav-rail" />
|
||||
|
||||
<div class="layout-shell__main">
|
||||
<!-- Header bar at top of content area -->
|
||||
<app-header-bar class="layout-shell__header" />
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<main class="layout-shell__content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Bottom Navigation Bar -->
|
||||
<app-bottom-nav class="layout-shell__bottom-nav" />
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
// ============================================================================
|
||||
// Layout Shell — Adaptive layout container
|
||||
// Desktop: Nav Rail (left) + Main Content (right)
|
||||
// Mobile: Header + Content + Bottom Nav (stacked)
|
||||
// ============================================================================
|
||||
|
||||
.layout-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-background);
|
||||
}
|
||||
|
||||
.layout-shell__nav-rail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; // Prevent flex overflow
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-shell__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--cc-section-padding);
|
||||
}
|
||||
|
||||
.layout-shell__bottom-nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Mobile: Stack layout vertically, add bottom padding for bottom nav
|
||||
@media (max-width: 599px) {
|
||||
.layout-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
// Account for bottom nav bar height
|
||||
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet: Ensure content padding accommodates collapsed nav rail
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.layout-shell__content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NavRailComponent } from '../nav-rail/nav-rail.component';
|
||||
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
|
||||
import { HeaderBarComponent } from '../header-bar/header-bar.component';
|
||||
|
||||
/**
|
||||
* Layout Shell — wraps the main content area with adaptive navigation.
|
||||
* Desktop/Kiosk: Nav Rail (left) + Header + Content
|
||||
* Mobile: Header + Content + Bottom Nav
|
||||
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-layout-shell',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent],
|
||||
templateUrl: './layout-shell.component.html',
|
||||
styleUrl: './layout-shell.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LayoutShellComponent {}
|
||||
44
frontend/src/app/layout/nav-rail/nav-rail.component.html
Normal file
44
frontend/src/app/layout/nav-rail/nav-rail.component.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<aside
|
||||
class="nav-rail"
|
||||
[class.nav-rail--expanded]="expanded()"
|
||||
[attr.aria-label]="'Navigation'"
|
||||
>
|
||||
<!-- Header with OpenClaw brand -->
|
||||
<div class="nav-rail__header">
|
||||
<button
|
||||
class="nav-rail__toggle"
|
||||
(click)="toggleExpand()"
|
||||
[attr.aria-label]="expanded() ? 'Collapse navigation' : 'Expand navigation'"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__brand">OpenClaw</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation destinations -->
|
||||
<nav class="nav-rail__nav">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="nav-rail__item--active"
|
||||
[attr.aria-label]="dest.label"
|
||||
class="nav-rail__item"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__label">{{ dest.label }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
112
frontend/src/app/layout/nav-rail/nav-rail.component.scss
Normal file
112
frontend/src/app/layout/nav-rail/nav-rail.component.scss
Normal file
@@ -0,0 +1,112 @@
|
||||
// ============================================================================
|
||||
// Nav Rail — Desktop/Kiosk Navigation
|
||||
// Per spec Section 3.1: 72px collapsed / 256px expanded
|
||||
// Section 5.4: Spacing & Grid
|
||||
// ============================================================================
|
||||
|
||||
.nav-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--cc-nav-rail-collapsed-width);
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
|
||||
&--expanded {
|
||||
width: var(--cc-nav-rail-expanded-width);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 12px;
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.nav-rail__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--cc-on-surface);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--status-active);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav-rail__nav {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
|
||||
// Override Angular Material list item styles for compact nav rail items
|
||||
--mat-list-list-item-one-line-vertical-gap: 4px;
|
||||
}
|
||||
|
||||
.nav-rail__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 0 12px;
|
||||
border-radius: 28px;
|
||||
margin: 2px 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
|
||||
.nav-rail__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Responsive: Hide nav rail on mobile (bottom nav takes over)
|
||||
@media (max-width: 599px) {
|
||||
.nav-rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/layout/nav-rail/nav-rail.component.ts
Normal file
32
frontend/src/app/layout/nav-rail/nav-rail.component.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component, signal, HostListener } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-rail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './nav-rail.component.html',
|
||||
styleUrl: './nav-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavRailComponent {
|
||||
protected readonly destinations = NAV_DESTINATIONS;
|
||||
protected readonly expanded = signal(false);
|
||||
|
||||
@HostListener('mouseenter')
|
||||
onHoverIn(): void {
|
||||
this.expanded.set(true);
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
onHoverOut(): void {
|
||||
this.expanded.set(false);
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
this.expanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user