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() {
-
- {MOBILE_NAV_LINKS.map((link) => (
-
- {link.label}
-
- ))}
-
+
)
}
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}
+
+
+
+ {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) => (
+ {
+ onSelect(chip.id)
+ }}
+ >
+ {chip.label}
+
+ ))}
+
+ )
+}
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 (
+
+ {links.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ )
+}
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={View Events }
+ />
+ }
+ 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 (
-
-
-
+ }
+ >
{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;
}
}