From f26b41e7dbbd4d95ec9322e89e120586ff460504 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 8 May 2026 21:34:38 -0700 Subject: [PATCH] feat: add shared UI primitives --- ui/src/app/layout/AppShell.tsx | 15 +- ui/src/components/ui/CameraCard.tsx | 58 ++++ ui/src/components/ui/EmptyState.tsx | 29 ++ ui/src/components/ui/EventCard.tsx | 73 +++++ ui/src/components/ui/FilterChips.tsx | 37 +++ ui/src/components/ui/MediaPanel.tsx | 51 ++++ ui/src/components/ui/MobileBottomNav.tsx | 36 +++ ui/src/components/ui/ResponsivePageShell.tsx | 36 +++ ui/src/components/ui/RiskBadge.tsx | 25 ++ ui/src/components/ui/StatusBadge.tsx | 18 +- .../ui/TechnicalDetailsDisclosure.tsx | 23 ++ ui/src/components/ui/primitives.test.tsx | 179 +++++++++++ ui/src/components/ui/riskTone.ts | 17 ++ ui/src/features/live/LivePage.tsx | 72 +++-- ui/src/styles/global.css | 283 +++++++++++++++++- 15 files changed, 894 insertions(+), 58 deletions(-) create mode 100644 ui/src/components/ui/CameraCard.tsx create mode 100644 ui/src/components/ui/EmptyState.tsx create mode 100644 ui/src/components/ui/EventCard.tsx create mode 100644 ui/src/components/ui/FilterChips.tsx create mode 100644 ui/src/components/ui/MediaPanel.tsx create mode 100644 ui/src/components/ui/MobileBottomNav.tsx create mode 100644 ui/src/components/ui/ResponsivePageShell.tsx create mode 100644 ui/src/components/ui/RiskBadge.tsx create mode 100644 ui/src/components/ui/TechnicalDetailsDisclosure.tsx create mode 100644 ui/src/components/ui/primitives.test.tsx create mode 100644 ui/src/components/ui/riskTone.ts diff --git a/ui/src/app/layout/AppShell.tsx b/ui/src/app/layout/AppShell.tsx index 036fee42..e2fe51ee 100644 --- a/ui/src/app/layout/AppShell.tsx +++ b/ui/src/app/layout/AppShell.tsx @@ -1,6 +1,7 @@ import { NavLink, Outlet } from 'react-router-dom' import { useHealthQuery } from '../../api/hooks/useHealthQuery' +import { MobileBottomNav, type MobileBottomNavLink } from '../../components/ui/MobileBottomNav' import { useTheme } from '../providers/theme-context' const DESKTOP_NAV_LINKS = [ @@ -11,7 +12,7 @@ const DESKTOP_NAV_LINKS = [ { to: '/system', label: 'System' }, ] -const MOBILE_NAV_LINKS = [ +const MOBILE_NAV_LINKS: readonly MobileBottomNavLink[] = [ { to: '/live', label: 'Live' }, { to: '/events', label: 'Events' }, { to: '/cameras', label: 'Cameras' }, @@ -22,10 +23,6 @@ function navLinkClassName({ isActive }: { isActive: boolean }): string { return isActive ? 'nav-link nav-link--active' : 'nav-link' } -function mobileNavLinkClassName({ isActive }: { isActive: boolean }): string { - return isActive ? 'mobile-nav-link mobile-nav-link--active' : 'mobile-nav-link' -} - function systemStatusText(status: string | undefined, isError: boolean): string { if (isError) { return 'System unavailable' @@ -72,13 +69,7 @@ export function AppShell() { - + ) } diff --git a/ui/src/components/ui/CameraCard.tsx b/ui/src/components/ui/CameraCard.tsx new file mode 100644 index 00000000..a8e5e059 --- /dev/null +++ b/ui/src/components/ui/CameraCard.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react' + +interface CameraCardMetaItem { + label: string + value: ReactNode +} + +interface CameraCardProps { + title: string + subtitle?: ReactNode + status?: ReactNode + media?: ReactNode + meta?: readonly CameraCardMetaItem[] + actions?: ReactNode + technicalDetails?: ReactNode + className?: string +} + +export function CameraCard({ + title, + subtitle, + status, + media, + meta, + actions, + technicalDetails, + className, +}: CameraCardProps) { + const classes = className ? `camera-card ${className}` : 'camera-card' + + return ( +
+
+
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ {status ?
{status}
: null} +
+ + {media ?
{media}
: null} + + {meta && meta.length > 0 ? ( +
+ {meta.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) : null} + + {technicalDetails ?
{technicalDetails}
: null} + {actions ?
{actions}
: null} +
+ ) +} diff --git a/ui/src/components/ui/EmptyState.tsx b/ui/src/components/ui/EmptyState.tsx new file mode 100644 index 00000000..14bafe5a --- /dev/null +++ b/ui/src/components/ui/EmptyState.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' + +type EmptyStateTone = 'neutral' | 'loading' | 'error' + +interface EmptyStateProps { + title: string + description?: ReactNode + action?: ReactNode + tone?: EmptyStateTone +} + +export function EmptyState({ + title, + description, + action, + tone = 'neutral', +}: EmptyStateProps) { + return ( +
+

{title}

+ {description ?

{description}

: null} + {action ?
{action}
: null} +
+ ) +} diff --git a/ui/src/components/ui/EventCard.tsx b/ui/src/components/ui/EventCard.tsx new file mode 100644 index 00000000..3cea1d87 --- /dev/null +++ b/ui/src/components/ui/EventCard.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react' + +interface EventCardMetaItem { + label: string + value: ReactNode +} + +interface EventCardProps { + camera: ReactNode + time: ReactNode + title?: ReactNode + summary?: ReactNode + media?: ReactNode + risk?: ReactNode + status?: ReactNode + meta?: readonly EventCardMetaItem[] + actions?: ReactNode + technicalDetails?: ReactNode + className?: string +} + +export function EventCard({ + camera, + time, + title, + summary, + media, + risk, + status, + meta, + actions, + technicalDetails, + className, +}: EventCardProps) { + const classes = className ? `event-card ${className}` : 'event-card' + + return ( +
+ {media ?
{media}
: null} +
+
+
+

{camera}

+

{time}

+
+ {risk || status ? ( +
+ {risk} + {status} +
+ ) : null} +
+ + {title ?

{title}

: null} + {summary ?

{summary}

: null} + + {meta && meta.length > 0 ? ( +
+ {meta.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) : null} + + {technicalDetails ?
{technicalDetails}
: null} + {actions ?
{actions}
: null} +
+
+ ) +} diff --git a/ui/src/components/ui/FilterChips.tsx b/ui/src/components/ui/FilterChips.tsx new file mode 100644 index 00000000..a9244074 --- /dev/null +++ b/ui/src/components/ui/FilterChips.tsx @@ -0,0 +1,37 @@ +export interface FilterChip { + id: string + label: string + active?: boolean + disabled?: boolean +} + +interface FilterChipsProps { + chips: readonly FilterChip[] + onSelect: (id: string) => void + ariaLabel?: string +} + +export function FilterChips({ + chips, + onSelect, + ariaLabel = 'Quick filters', +}: FilterChipsProps) { + return ( +
+ {chips.map((chip) => ( + + ))} +
+ ) +} diff --git a/ui/src/components/ui/MediaPanel.tsx b/ui/src/components/ui/MediaPanel.tsx new file mode 100644 index 00000000..835275b9 --- /dev/null +++ b/ui/src/components/ui/MediaPanel.tsx @@ -0,0 +1,51 @@ +import type { PropsWithChildren, ReactNode } from 'react' + +type MediaPanelAspect = 'video' | 'square' | 'auto' + +interface MediaPanelProps extends PropsWithChildren { + title?: string + subtitle?: ReactNode + status?: ReactNode + actions?: ReactNode + placeholder?: ReactNode + aspect?: MediaPanelAspect + className?: string +} + +export function MediaPanel({ + title, + subtitle, + status, + actions, + placeholder, + aspect = 'video', + className, + children, +}: MediaPanelProps) { + const classes = className ? `media-panel ${className}` : 'media-panel' + const viewportClasses = `media-panel__viewport media-panel__viewport--${aspect}` + const hasHeader = title || subtitle || status || actions + + return ( +
+ {hasHeader ? ( +
+
+ {title ?

{title}

: null} + {subtitle ?

{subtitle}

: null} +
+ {status || actions ? ( +
+ {status} + {actions} +
+ ) : null} +
+ ) : null} + +
+ {children ??
{placeholder ?? 'Media unavailable'}
} +
+
+ ) +} diff --git a/ui/src/components/ui/MobileBottomNav.tsx b/ui/src/components/ui/MobileBottomNav.tsx new file mode 100644 index 00000000..ff1975f0 --- /dev/null +++ b/ui/src/components/ui/MobileBottomNav.tsx @@ -0,0 +1,36 @@ +import { NavLink } from 'react-router-dom' + +export interface MobileBottomNavLink { + to: string + label: string + end?: boolean +} + +interface MobileBottomNavProps { + links: readonly MobileBottomNavLink[] + ariaLabel?: string +} + +function mobileNavLinkClassName({ isActive }: { isActive: boolean }): string { + return isActive ? 'mobile-nav-link mobile-nav-link--active' : 'mobile-nav-link' +} + +export function MobileBottomNav({ + links, + ariaLabel = 'Mobile primary', +}: MobileBottomNavProps) { + return ( + + ) +} diff --git a/ui/src/components/ui/ResponsivePageShell.tsx b/ui/src/components/ui/ResponsivePageShell.tsx new file mode 100644 index 00000000..a6c8dfdd --- /dev/null +++ b/ui/src/components/ui/ResponsivePageShell.tsx @@ -0,0 +1,36 @@ +import type { PropsWithChildren, ReactNode } from 'react' + +interface ResponsivePageShellProps extends PropsWithChildren { + title: string + lead?: ReactNode + eyebrow?: ReactNode + actions?: ReactNode + className?: string +} + +export function ResponsivePageShell({ + title, + lead, + eyebrow, + actions, + className, + children, +}: ResponsivePageShellProps) { + const classes = className + ? `page responsive-page-shell fade-in-up ${className}` + : 'page responsive-page-shell fade-in-up' + + return ( +
+
+
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {lead ?

{lead}

: null} +
+ {actions ?
{actions}
: null} +
+ {children} +
+ ) +} diff --git a/ui/src/components/ui/RiskBadge.tsx b/ui/src/components/ui/RiskBadge.tsx new file mode 100644 index 00000000..57dfbd5b --- /dev/null +++ b/ui/src/components/ui/RiskBadge.tsx @@ -0,0 +1,25 @@ +import type { HTMLAttributes } from 'react' + +import { riskToneForLevel } from './riskTone' + +interface RiskBadgeProps extends HTMLAttributes { + level: string | null | undefined +} + +function riskLabel(level: string | null | undefined): string { + const trimmed = level?.trim() + return trimmed ? trimmed : 'Risk unavailable' +} + +export function RiskBadge({ level, className, role, ...props }: RiskBadgeProps) { + const tone = riskToneForLevel(level) + const classes = className + ? `risk-badge risk-badge--${tone} ${className}` + : `risk-badge risk-badge--${tone}` + + return ( + + {riskLabel(level)} + + ) +} diff --git a/ui/src/components/ui/StatusBadge.tsx b/ui/src/components/ui/StatusBadge.tsx index 893097d2..73953681 100644 --- a/ui/src/components/ui/StatusBadge.tsx +++ b/ui/src/components/ui/StatusBadge.tsx @@ -1,13 +1,19 @@ -type BadgeTone = 'healthy' | 'degraded' | 'unhealthy' | 'unknown' +import type { HTMLAttributes, ReactNode } from 'react' -interface StatusBadgeProps { - tone: BadgeTone - children: string +export type StatusBadgeTone = 'healthy' | 'degraded' | 'unhealthy' | 'unknown' + +interface StatusBadgeProps extends HTMLAttributes { + tone: StatusBadgeTone + children: ReactNode } -export function StatusBadge({ tone, children }: StatusBadgeProps) { +export function StatusBadge({ tone, children, className, role, ...props }: StatusBadgeProps) { + const classes = className + ? `status-badge status-badge--${tone} ${className}` + : `status-badge status-badge--${tone}` + return ( - + {children} ) diff --git a/ui/src/components/ui/TechnicalDetailsDisclosure.tsx b/ui/src/components/ui/TechnicalDetailsDisclosure.tsx new file mode 100644 index 00000000..52a1b1b3 --- /dev/null +++ b/ui/src/components/ui/TechnicalDetailsDisclosure.tsx @@ -0,0 +1,23 @@ +import type { PropsWithChildren, ReactNode } from 'react' + +interface TechnicalDetailsDisclosureProps extends PropsWithChildren { + summary?: ReactNode + className?: string +} + +export function TechnicalDetailsDisclosure({ + summary = 'Technical details', + className, + children, +}: TechnicalDetailsDisclosureProps) { + const classes = className + ? `technical-details ${className}` + : 'technical-details' + + return ( +
+ {summary} +
{children}
+
+ ) +} diff --git a/ui/src/components/ui/primitives.test.tsx b/ui/src/components/ui/primitives.test.tsx new file mode 100644 index 00000000..b26c8261 --- /dev/null +++ b/ui/src/components/ui/primitives.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' + +import { CameraCard } from './CameraCard' +import { EmptyState } from './EmptyState' +import { EventCard } from './EventCard' +import { FilterChips } from './FilterChips' +import { MediaPanel } from './MediaPanel' +import { MobileBottomNav } from './MobileBottomNav' +import { ResponsivePageShell } from './ResponsivePageShell' +import { RiskBadge } from './RiskBadge' +import { StatusBadge } from './StatusBadge' +import { TechnicalDetailsDisclosure } from './TechnicalDetailsDisclosure' +import { riskToneForLevel } from './riskTone' + +describe('shared UI primitives', () => { + afterEach(() => { + cleanup() + }) + + it('renders responsive page shell actions without coupling to page data', () => { + // Given: A page using the shared responsive shell + render( + Refresh}> +

Page body

+
, + ) + + // When: The shell is rendered + const heading = screen.getByRole('heading', { name: 'Live' }) + + // Then: The page hierarchy and action region are available to concrete pages + expect(heading).toBeTruthy() + expect(screen.getByText('Camera-first view')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Refresh' })).toBeTruthy() + expect(screen.getByText('Page body')).toBeTruthy() + }) + + it('renders mobile bottom navigation from caller-provided links', () => { + // Given: Mobile nav is configured with homeowner destinations only + render( + + + , + ) + + // When: The mobile navigation is inspected + const mobileNav = screen.getByRole('navigation', { name: 'Mobile primary' }) + + // Then: It exposes the configured links without adding System implicitly + expect(within(mobileNav).getByRole('link', { name: 'Live' }).getAttribute('href')).toBe('/live') + expect(within(mobileNav).getByRole('link', { name: 'Events' }).getAttribute('href')).toBe('/events') + expect(within(mobileNav).getByRole('link', { name: 'Settings' }).getAttribute('href')).toBe('/settings') + expect(within(mobileNav).queryByRole('link', { name: 'System' })).toBeNull() + }) + + it('renders status and risk badges from current API values', () => { + // Given: Status and risk values from existing frontend data + render( +
+ Online + + +
, + ) + + // When: Badges are rendered + const badges = screen.getAllByRole('status') + + // Then: Existing values are displayed without requiring extra backend reason codes + expect(badges.map((badge) => badge.textContent)).toEqual([ + 'Online', + 'high', + 'Risk unavailable', + ]) + expect(riskToneForLevel('moderate')).toBe('medium') + expect(riskToneForLevel('unknown backend value')).toBe('unknown') + }) + + it('hides technical details behind a disclosure by default', () => { + // Given: Technical fields need a reusable collapsed container + render( + +
{'{"clip_id":"abc"}'}
+
, + ) + + // When: The disclosure is rendered + const summary = screen.getByText('Technical details') + const details = summary.closest('details') + + // Then: The details are collapsed until the user opens them + expect(details?.hasAttribute('open')).toBe(false) + expect(screen.getByText('{"clip_id":"abc"}')).toBeTruthy() + }) + + it('invokes quick filter chip selections with active state exposed', async () => { + // Given: Quick filters are rendered from caller-owned state + const user = userEvent.setup() + const onSelect = vi.fn() + render( + , + ) + + // When: A chip is selected + await user.click(screen.getByRole('button', { name: 'Alerts' })) + + // Then: The caller receives the selected id and active state remains accessible + expect(onSelect).toHaveBeenCalledWith('alerts') + expect(screen.getByRole('button', { name: 'Today' }).getAttribute('aria-pressed')).toBe('true') + }) + + it('renders empty and media states without page-specific data', () => { + // Given: Shared empty and media states are used by future M1 pages + render( +
+ Events} /> + Idle} /> +
, + ) + + // When: The primitives render their default surfaces + const mediaPanel = screen.getByRole('heading', { name: 'Event video' }).closest('section') + + // Then: Empty copy, action, and media placeholder are available + expect(screen.getByRole('heading', { name: 'No events' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'Events' }).getAttribute('href')).toBe('/events') + expect(mediaPanel ? within(mediaPanel).getByText('Media unavailable') : null).toBeTruthy() + }) + + it('renders camera and event cards with caller-provided metadata and actions', () => { + // Given: Card primitives receive lightweight display props, not API models + render( +
+ Online} + meta={[{ label: 'Last seen', value: 'Today' }]} + actions={} + /> + } + meta={[{ label: 'Objects', value: 'person' }]} + actions={Open} + /> +
, + ) + + // When: The cards are rendered + const cameraCard = screen.getByRole('heading', { name: 'Front door' }).closest('article') + const eventCard = screen.getByText('Driveway').closest('article') + + // Then: Common card structure can support Live and Events without data fetching + expect(cameraCard ? within(cameraCard).getByText('Last seen') : null).toBeTruthy() + expect(cameraCard ? within(cameraCard).getByRole('button', { name: 'View Events' }) : null).toBeTruthy() + expect(eventCard ? within(eventCard).getByText('Person near the driveway') : null).toBeTruthy() + expect(eventCard ? within(eventCard).getByRole('link', { name: 'Open' }).getAttribute('href') : null).toBe('/events/clip-1') + }) +}) diff --git a/ui/src/components/ui/riskTone.ts b/ui/src/components/ui/riskTone.ts new file mode 100644 index 00000000..c7cd0208 --- /dev/null +++ b/ui/src/components/ui/riskTone.ts @@ -0,0 +1,17 @@ +export type RiskBadgeTone = 'low' | 'medium' | 'high' | 'critical' | 'unknown' + +export function riskToneForLevel(level: string | null | undefined): RiskBadgeTone { + switch (level?.trim().toLowerCase()) { + case 'low': + return 'low' + case 'medium': + case 'moderate': + return 'medium' + case 'high': + return 'high' + case 'critical': + return 'critical' + default: + return 'unknown' + } +} diff --git a/ui/src/features/live/LivePage.tsx b/ui/src/features/live/LivePage.tsx index 3868f18c..198f6629 100644 --- a/ui/src/features/live/LivePage.tsx +++ b/ui/src/features/live/LivePage.tsx @@ -4,7 +4,10 @@ import { clearApiKey, isUnauthorizedAPIError, saveApiKey } from '../../api/clien import { useCamerasQuery } from '../../api/hooks/useCamerasQuery' import { ApiKeyGate } from '../../components/ui/ApiKeyGate' import { Button } from '../../components/ui/Button' +import { CameraCard } from '../../components/ui/CameraCard' import { Card } from '../../components/ui/Card' +import { EmptyState } from '../../components/ui/EmptyState' +import { ResponsivePageShell } from '../../components/ui/ResponsivePageShell' import { StatusBadge } from '../../components/ui/StatusBadge' import { describeCameraError } from '../cameras/presentation' import { useSetupRedirect } from '../setup/useSetupRedirect' @@ -52,17 +55,15 @@ export function LivePage() { } return ( -
-
-
-

Live

-

See your cameras first, then jump to camera controls or events.

-
+ {camerasQuery.isFetching ? 'Refreshing...' : 'Refresh'} -
- + } + > {unauthorized ? ( -

Checking configured cameras...

-
+ ) : null} {!camerasQuery.isPending && !camerasQuery.error && cameras.length === 0 ? ( - -

Add a camera in Settings to start using live view.

-
+ Open Settings -
-
+ } + /> ) : null} {cameras.length > 0 ? (
{cameras.map((camera) => ( -
-
-

{camera.name}

-

{camera.source_backend}

-
- - {cameraStatusLabel(camera.enabled, camera.healthy)} - -
+ + {cameraStatusLabel(camera.enabled, camera.healthy)} + + } + actions={ + <> Camera controls - - View Events - -
-
+ + View Events + + + } + /> ))}
) : null} -
+ ) } diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index 3c85efee..bccbc98e 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -172,6 +172,22 @@ a:hover { color: var(--text-secondary); } +.responsive-page-shell__eyebrow { + margin: 0 0 var(--space-2); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.responsive-page-shell__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + .grid { display: grid; gap: var(--space-4); @@ -347,6 +363,186 @@ a:hover { color: var(--text-secondary); } +.risk-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid transparent; + padding: 0.2rem 0.58rem; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.risk-badge--low { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 35%, transparent); + background: color-mix(in srgb, var(--success) 12%, transparent); +} + +.risk-badge--medium { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning) 40%, transparent); + background: color-mix(in srgb, var(--warning) 12%, transparent); +} + +.risk-badge--high, +.risk-badge--critical { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 45%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} + +.risk-badge--unknown { + color: var(--text-secondary); + border-color: var(--line); + background: color-mix(in srgb, var(--surface-2) 75%, transparent); +} + +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.filter-chip { + min-height: 2.35rem; + border: 1px solid var(--line); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-1) 76%, transparent); + color: var(--text-secondary); + padding: 0.42rem 0.75rem; + font: inherit; + font-size: 0.86rem; + cursor: pointer; +} + +.filter-chip:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + color: var(--text-primary); +} + +.filter-chip--active { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--text-primary); +} + +.filter-chip:disabled { + cursor: wait; + opacity: 0.62; +} + +.empty-state { + display: grid; + gap: var(--space-2); + justify-items: start; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--surface-1) 72%, transparent); + padding: var(--space-4); +} + +.empty-state--error { + border-color: color-mix(in srgb, var(--danger) 42%, transparent); + background: color-mix(in srgb, var(--danger) 8%, var(--surface-1)); +} + +.empty-state__title { + margin: 0; + font-size: 1rem; +} + +.empty-state__description { + margin: 0; + color: var(--text-secondary); +} + +.empty-state__action { + margin-top: var(--space-2); +} + +.technical-details { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--surface-1) 66%, transparent); +} + +.technical-details__summary { + cursor: pointer; + padding: 0.65rem 0.75rem; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.82rem; +} + +.technical-details__body { + border-top: 1px solid var(--line); + padding: var(--space-3); +} + +.media-panel { + display: grid; + gap: var(--space-2); +} + +.media-panel__header, +.camera-card__header, +.event-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2); +} + +.media-panel__title, +.camera-card__title, +.event-card__title { + margin: 0; + font-size: 1rem; +} + +.media-panel__subtitle, +.camera-card__subtitle, +.event-card__time, +.event-card__summary { + margin: 0.25rem 0 0; + color: var(--text-secondary); +} + +.media-panel__header-actions, +.event-card__badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.35rem; +} + +.media-panel__viewport { + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--surface-0) 88%, transparent); +} + +.media-panel__viewport--video { + aspect-ratio: 16 / 9; +} + +.media-panel__viewport--square { + aspect-ratio: 1; +} + +.media-panel__placeholder { + display: grid; + min-height: 10rem; + place-items: center; + padding: var(--space-3); + color: var(--text-secondary); + text-align: center; +} + .live-camera-list, .settings-grid { display: grid; @@ -357,10 +553,9 @@ a:hover { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } -.live-camera-row { +.camera-card, +.event-card { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; gap: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-sm); @@ -368,13 +563,71 @@ a:hover { padding: var(--space-4); } -.live-camera-row__title { - margin: 0 0 0.3rem; - font-size: 1rem; +.camera-card__status { + display: flex; + justify-content: flex-end; +} + +.camera-card__media, +.event-card__media { + min-width: 0; +} + +.camera-card__meta, +.event-card__meta { + margin: 0; + display: grid; + gap: var(--space-2); +} + +.camera-card__meta-row, +.event-card__meta-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: var(--space-2); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--surface-1) 68%, transparent); + padding: 0.55rem 0.65rem; +} + +.camera-card__meta-row dt, +.event-card__meta-row dt { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.78rem; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.camera-card__meta-row dd, +.event-card__meta-row dd { + margin: 0; + color: var(--text-primary); } -.live-camera-row__actions { +.camera-card__actions, +.event-card__actions { + display: flex; + flex-wrap: wrap; justify-content: flex-end; + gap: var(--space-2); +} + +.event-card { + grid-template-columns: minmax(180px, 260px) minmax(0, 1fr); +} + +.event-card__content { + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.event-card__camera { + margin: 0; + font-weight: 600; } .clips-mobile-list { @@ -1114,6 +1367,10 @@ a:hover { flex-direction: column; } + .responsive-page-shell__actions { + justify-content: flex-start; + } + .clips-filter-grid { grid-template-columns: 1fr; } @@ -1155,12 +1412,20 @@ a:hover { justify-content: flex-start; } - .live-camera-row { + .camera-card__header, + .event-card, + .event-card__header, + .media-panel__header { grid-template-columns: 1fr; + flex-direction: column; align-items: flex-start; } - .live-camera-row__actions { + .camera-card__actions, + .camera-card__status, + .event-card__actions, + .event-card__badges, + .media-panel__header-actions { justify-content: flex-start; } }