Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw

This commit is contained in:
ejclaw
2026-04-28 10:54:45 +09:00
3 changed files with 554 additions and 480 deletions

View File

@@ -1,17 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Activity,
Clock,
Download,
Gauge,
Inbox as InboxIcon,
MessageSquare,
RefreshCw,
Send,
Settings,
} from 'lucide-react';
import { Send } from 'lucide-react';
import {
type ClaudeAccountSummary,
@@ -61,6 +51,12 @@ import {
useSelectedRoomActivity,
type RoomActivityMap,
} from './useRoomActivity';
import {
SectionNav,
SideRail,
type DashboardFreshness,
type DashboardView,
} from './DashboardNav';
import './styles.css';
interface DashboardState {
@@ -74,13 +70,6 @@ type InboxItem = DashboardOverview['inbox'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex';
type UsageLimitWindow = 'h5' | 'd7';
type DashboardView =
| 'usage'
| 'inbox'
| 'health'
| 'rooms'
| 'scheduled'
| 'settings';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskResultTone = 'ok' | 'fail' | 'none';
type TaskActionKey =
@@ -91,7 +80,7 @@ type InboxActionKey = `${string}:${DashboardInboxAction}`;
type ServiceActionKey = 'stack:restart';
type InboxFilter = 'all' | InboxItem['kind'];
type HealthLevel = 'ok' | 'stale' | 'down';
type FreshnessLevel = 'fresh' | 'stale' | 'offline';
type FreshnessLevel = DashboardFreshness;
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
@@ -1005,26 +994,6 @@ function inboxTargetHref(item: InboxItem): string | null {
return null;
}
const NAV_ICONS: Record<DashboardView, ReactNode> = {
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
inbox: <InboxIcon size={20} strokeWidth={2} aria-hidden />,
health: <Activity size={20} strokeWidth={2} aria-hidden />,
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
};
function navItems(t: Messages) {
return [
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
{ href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const },
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
{ href: '#/health', label: t.nav.health, view: 'health' as const },
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
{ href: '#/settings', label: t.nav.settings, view: 'settings' as const },
];
}
function EmptyState({ children }: { children: ReactNode }) {
return <div className="empty-state">{children}</div>;
}
@@ -1668,269 +1637,6 @@ function LoadingSkeleton({ t }: { t: Messages }) {
);
}
function SideRail({
activeView,
canInstall,
data,
installed,
onNavigate,
onInstall,
onRefresh,
online,
offlineReady,
refreshing,
secureContext,
t,
}: {
activeView: DashboardView;
canInstall: boolean;
data: DashboardState | null;
installed: boolean;
onNavigate: (view: DashboardView) => void;
onInstall: () => void;
onRefresh: () => void;
online: boolean;
offlineReady: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
const pwaState = !secureContext
? t.pwa.secureRequired
: installed
? t.pwa.installed
: offlineReady
? t.pwa.ready
: t.pwa.app;
const stats = data
? (() => {
const queue = data.snapshots.reduce(
(acc, snapshot) => {
for (const entry of snapshot.entries) {
acc.pendingTasks += entry.pendingTasks;
if (entry.pendingMessages) acc.pendingMessageRooms += 1;
if (entry.status === 'processing') acc.processing += 1;
}
return acc;
},
{ pendingTasks: 0, pendingMessageRooms: 0, processing: 0 },
);
return {
rooms: data.overview.rooms,
inbox: data.overview.inbox.length,
processing: queue.processing,
pendingTasks: queue.pendingTasks,
pendingMessageRooms: queue.pendingMessageRooms,
};
})()
: null;
return (
<aside className="side-rail icon-rail" aria-label={t.nav.drawerAria}>
<a
className="rail-brand"
href="#/usage"
onClick={() => onNavigate('usage')}
title="EJClaw"
>
EJ
</a>
<nav className="rail-nav" aria-label={t.nav.drawerNavAria}>
{navItems(t).map((item) => {
const badge =
item.view === 'inbox' && stats && stats.inbox > 0
? stats.inbox
: item.view === 'rooms' && stats && stats.processing > 0
? stats.processing
: null;
return (
<a
aria-current={activeView === item.view ? 'page' : undefined}
aria-label={item.label}
className={`rail-item${activeView === item.view ? ' is-active' : ''}`}
href={item.href}
key={item.href}
onClick={() => onNavigate(item.view)}
title={item.label}
>
<span className="rail-icon">{NAV_ICONS[item.view]}</span>
{badge !== null ? (
<span className="rail-badge">{badge}</span>
) : null}
</a>
);
})}
</nav>
<div className="rail-foot">
<span
className={`rail-status-dot ${online ? 'is-online' : 'is-offline'}`}
title={`${online ? t.pwa.online : t.pwa.offline} · ${pwaState}`}
aria-label={`${online ? t.pwa.online : t.pwa.offline}`}
/>
{canInstall ? (
<button
className="rail-btn"
onClick={onInstall}
title={t.pwa.install}
aria-label={t.pwa.install}
type="button"
>
<Download size={16} strokeWidth={2} aria-hidden />
</button>
) : null}
<button
aria-busy={refreshing}
aria-label={t.actions.refresh}
className={`rail-btn${refreshing ? ' is-spinning' : ''}`}
disabled={refreshing}
onClick={onRefresh}
title={refreshing ? t.actions.refreshing : t.actions.refresh}
type="button"
>
<RefreshCw size={16} strokeWidth={2} aria-hidden />
</button>
</div>
</aside>
);
}
function SectionNav({
activeView,
drawerOpen,
freshness,
installed,
locale,
canInstall,
onCloseDrawer,
onInstall,
onLocaleChange,
onNavigate,
onOpenDrawer,
offlineReady,
refreshing,
onRefresh,
secureContext,
t,
}: {
activeView: DashboardView;
drawerOpen: boolean;
freshness: FreshnessLevel;
installed: boolean;
locale: Locale;
canInstall: boolean;
onCloseDrawer: () => void;
onInstall: () => void;
onLocaleChange: (locale: Locale) => void;
onNavigate: (view: DashboardView) => void;
onOpenDrawer: () => void;
offlineReady: boolean;
refreshing: boolean;
onRefresh: () => void;
secureContext: boolean;
t: Messages;
}) {
const activeLabel =
navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms;
return (
<>
<nav className="section-nav" aria-label={t.nav.aria}>
<button
aria-controls="dashboard-menu"
aria-expanded={drawerOpen}
aria-label={drawerOpen ? t.nav.menuClose : t.nav.menuOpen}
className="menu-button"
onClick={drawerOpen ? onCloseDrawer : onOpenDrawer}
type="button"
>
<span />
<span />
<span />
</button>
<strong className="topbar-label">{activeLabel}</strong>
<span className={`topbar-status topbar-status-${freshness}`}>
{freshnessLabel(freshness, t)}
</span>
<button
aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
className="refresh-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
{refreshing ? '...' : t.actions.refresh}
</button>
</nav>
{drawerOpen ? (
<>
<button
aria-label={t.nav.menuClose}
className="drawer-backdrop"
onClick={onCloseDrawer}
type="button"
/>
<aside
aria-label={t.nav.drawerAria}
aria-modal="true"
className="nav-drawer"
id="dashboard-menu"
role="dialog"
>
<div className="drawer-head">
<div>
<span className="eyebrow">EJClaw</span>
<strong>{t.nav.operations}</strong>
</div>
<button
aria-label={t.nav.menuClose}
onClick={onCloseDrawer}
type="button"
>
{t.actions.close}
</button>
</div>
<nav aria-label={t.nav.drawerNavAria}>
{navItems(t).map((item) => (
<a
aria-current={activeView === item.view ? 'page' : undefined}
className={activeView === item.view ? 'is-active' : undefined}
href={item.href}
key={item.href}
onClick={() => {
onNavigate(item.view);
onCloseDrawer();
}}
>
{item.label}
</a>
))}
</nav>
<div className="drawer-pwa-row">
<span>
{!secureContext
? t.pwa.secureRequired
: installed
? t.pwa.installed
: offlineReady
? t.pwa.ready
: t.pwa.app}
</span>
{canInstall ? (
<button onClick={onInstall} type="button">
{t.pwa.install}
</button>
) : null}
</div>
</aside>
</>
) : null}
</>
);
}
function ControlRail({
canInstall,
data,
@@ -4024,6 +3730,30 @@ function TaskPanel({
);
}
function DashboardErrorCard({
error,
onRetry,
refreshing,
t,
}: {
error: string;
onRetry: () => void;
refreshing: boolean;
t: Messages;
}) {
return (
<section className="error-card" role="alert" aria-live="polite">
<span>
<strong>{t.error.api}</strong>
<small>{humanizeError(error, t)}</small>
</span>
<button disabled={refreshing} onClick={onRetry}>
{t.actions.retry}
</button>
</section>
);
}
function App() {
const [data, setData] = useState<DashboardState | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -4449,32 +4179,30 @@ function App() {
<SectionNav
activeView={activeView}
canInstall={canInstall}
data={data}
drawerOpen={drawerOpen}
freshness={freshness}
freshnessText={freshnessLabel(freshness, t)}
installed={installed}
locale={locale}
onCloseDrawer={() => setDrawerOpen(false)}
onInstall={() => void handleInstallApp()}
onLocaleChange={setDashboardLocale}
onNavigate={navigateToView}
onOpenDrawer={() => setDrawerOpen(true)}
offlineReady={offlineReady}
onRefresh={() => void refresh(true)}
online={online}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
{error ? (
<section className="error-card" role="alert" aria-live="polite">
<span>
<strong>{t.error.api}</strong>
<small>{humanizeError(error, t)}</small>
</span>
<button disabled={refreshing} onClick={() => void refresh(true)}>
{t.actions.retry}
</button>
</section>
<DashboardErrorCard
error={error}
onRetry={() => void refresh(true)}
refreshing={refreshing}
t={t}
/>
) : null}
{data ? (

View File

@@ -0,0 +1,394 @@
import { type ReactNode } from 'react';
import {
Activity,
Clock,
Download,
Gauge,
Inbox as InboxIcon,
MessageSquare,
RefreshCw,
Settings,
} from 'lucide-react';
import { type DashboardOverview, type StatusSnapshot } from './api';
import { type Messages } from './i18n';
export type DashboardView =
| 'usage'
| 'inbox'
| 'health'
| 'rooms'
| 'scheduled'
| 'settings';
export type DashboardFreshness = 'fresh' | 'stale' | 'offline';
interface DashboardNavData {
overview: DashboardOverview;
snapshots: StatusSnapshot[];
}
const NAV_ICONS: Record<DashboardView, ReactNode> = {
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
inbox: <InboxIcon size={20} strokeWidth={2} aria-hidden />,
health: <Activity size={20} strokeWidth={2} aria-hidden />,
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
};
function navItems(t: Messages) {
return [
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
{ href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const },
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
{ href: '#/health', label: t.nav.health, view: 'health' as const },
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
{ href: '#/settings', label: t.nav.settings, view: 'settings' as const },
];
}
function navStats(data: DashboardNavData | null) {
if (!data) return null;
const queue = data.snapshots.reduce(
(acc, snapshot) => {
for (const entry of snapshot.entries) {
if (entry.status === 'processing') acc.processing += 1;
}
return acc;
},
{ processing: 0 },
);
return {
inbox: data.overview.inbox.length,
processing: queue.processing,
};
}
function navBadge(
view: DashboardView,
stats: ReturnType<typeof navStats>,
): number | null {
if (view === 'inbox' && stats && stats.inbox > 0) return stats.inbox;
if (view === 'rooms' && stats && stats.processing > 0) {
return stats.processing;
}
return null;
}
function pwaStateLabel({
installed,
offlineReady,
secureContext,
t,
}: {
installed: boolean;
offlineReady: boolean;
secureContext: boolean;
t: Messages;
}) {
if (!secureContext) return t.pwa.secureRequired;
if (installed) return t.pwa.installed;
if (offlineReady) return t.pwa.ready;
return t.pwa.app;
}
function DashboardBrandLink({
onNavigate,
}: {
onNavigate: (view: DashboardView) => void;
}) {
return (
<a
className="rail-brand"
href="#/usage"
onClick={() => onNavigate('usage')}
title="EJClaw"
>
EJ
</a>
);
}
function DashboardNavLinks({
activeView,
data,
onAfterNavigate,
onNavigate,
t,
}: {
activeView: DashboardView;
data: DashboardNavData | null;
onAfterNavigate?: () => void;
onNavigate: (view: DashboardView) => void;
t: Messages;
}) {
const stats = navStats(data);
return (
<nav className="rail-nav" aria-label={t.nav.drawerNavAria}>
{navItems(t).map((item) => {
const badge = navBadge(item.view, stats);
return (
<a
aria-current={activeView === item.view ? 'page' : undefined}
aria-label={item.label}
className={`rail-item${activeView === item.view ? ' is-active' : ''}`}
href={item.href}
key={item.href}
onClick={() => {
onNavigate(item.view);
onAfterNavigate?.();
}}
title={item.label}
>
<span className="rail-icon">{NAV_ICONS[item.view]}</span>
<span className="rail-label">{item.label}</span>
{badge !== null ? (
<span className="rail-badge">{badge}</span>
) : null}
</a>
);
})}
</nav>
);
}
function DashboardNavActions({
canInstall,
installed,
offlineReady,
onInstall,
onRefresh,
online,
refreshing,
secureContext,
t,
}: {
canInstall: boolean;
installed: boolean;
offlineReady: boolean;
onInstall: () => void;
onRefresh: () => void;
online: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
const pwaState = pwaStateLabel({ installed, offlineReady, secureContext, t });
const status = `${online ? t.pwa.online : t.pwa.offline} · ${pwaState}`;
return (
<div className="rail-foot">
<div className="rail-status-line" title={status}>
<span
className={`rail-status-dot ${online ? 'is-online' : 'is-offline'}`}
aria-label={online ? t.pwa.online : t.pwa.offline}
/>
<span className="rail-foot-label">{status}</span>
</div>
<div className="rail-actions">
{canInstall ? (
<button
className="rail-btn"
onClick={onInstall}
title={t.pwa.install}
aria-label={t.pwa.install}
type="button"
>
<Download size={16} strokeWidth={2} aria-hidden />
<span className="rail-btn-label">{t.pwa.install}</span>
</button>
) : null}
<button
aria-busy={refreshing}
aria-label={t.actions.refresh}
className={`rail-btn${refreshing ? ' is-spinning' : ''}`}
disabled={refreshing}
onClick={onRefresh}
title={refreshing ? t.actions.refreshing : t.actions.refresh}
type="button"
>
<RefreshCw size={16} strokeWidth={2} aria-hidden />
<span className="rail-btn-label">
{refreshing ? t.actions.refreshing : t.actions.refresh}
</span>
</button>
</div>
</div>
);
}
export function SideRail({
activeView,
canInstall,
data,
installed,
offlineReady,
onInstall,
onNavigate,
onRefresh,
online,
refreshing,
secureContext,
t,
}: {
activeView: DashboardView;
canInstall: boolean;
data: DashboardNavData | null;
installed: boolean;
offlineReady: boolean;
onInstall: () => void;
onNavigate: (view: DashboardView) => void;
onRefresh: () => void;
online: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
return (
<aside className="side-rail icon-rail" aria-label={t.nav.drawerAria}>
<DashboardBrandLink onNavigate={onNavigate} />
<DashboardNavLinks
activeView={activeView}
data={data}
onNavigate={onNavigate}
t={t}
/>
<DashboardNavActions
canInstall={canInstall}
installed={installed}
offlineReady={offlineReady}
online={online}
onInstall={onInstall}
onRefresh={onRefresh}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
</aside>
);
}
export function SectionNav({
activeView,
canInstall,
data,
drawerOpen,
freshness,
freshnessText,
installed,
onCloseDrawer,
onInstall,
onNavigate,
onOpenDrawer,
offlineReady,
onRefresh,
online,
refreshing,
secureContext,
t,
}: {
activeView: DashboardView;
canInstall: boolean;
data: DashboardNavData | null;
drawerOpen: boolean;
freshness: DashboardFreshness;
freshnessText: string;
installed: boolean;
onCloseDrawer: () => void;
onInstall: () => void;
onNavigate: (view: DashboardView) => void;
onOpenDrawer: () => void;
offlineReady: boolean;
onRefresh: () => void;
online: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
const activeLabel =
navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms;
return (
<>
<nav className="section-nav" aria-label={t.nav.aria}>
<button
aria-controls="dashboard-menu"
aria-expanded={drawerOpen}
aria-label={drawerOpen ? t.nav.menuClose : t.nav.menuOpen}
className="menu-button"
onClick={drawerOpen ? onCloseDrawer : onOpenDrawer}
type="button"
>
<span />
<span />
<span />
</button>
<strong className="topbar-label">{activeLabel}</strong>
<span className={`topbar-status topbar-status-${freshness}`}>
{freshnessText}
</span>
<button
aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
className="refresh-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
{refreshing ? '...' : t.actions.refresh}
</button>
</nav>
{drawerOpen ? (
<>
<button
aria-label={t.nav.menuClose}
className="drawer-backdrop"
onClick={onCloseDrawer}
type="button"
/>
<aside
aria-label={t.nav.drawerAria}
aria-modal="true"
className="nav-drawer"
id="dashboard-menu"
role="dialog"
>
<div className="drawer-head">
<DashboardBrandLink onNavigate={onNavigate} />
<div className="drawer-title">
<span className="eyebrow">EJClaw</span>
<strong>{t.nav.operations}</strong>
</div>
<button
aria-label={t.nav.menuClose}
className="rail-btn drawer-close"
onClick={onCloseDrawer}
type="button"
>
{t.actions.close}
</button>
</div>
<DashboardNavLinks
activeView={activeView}
data={data}
onAfterNavigate={onCloseDrawer}
onNavigate={onNavigate}
t={t}
/>
<DashboardNavActions
canInstall={canInstall}
installed={installed}
offlineReady={offlineReady}
online={online}
onInstall={onInstall}
onRefresh={onRefresh}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
</aside>
</>
) : null}
</>
);
}

View File

@@ -547,8 +547,16 @@ button:disabled {
border-top: 1px solid var(--divider);
}
.rail-status-line {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.rail-status-dot {
display: inline-block;
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
@@ -565,6 +573,12 @@ button:disabled {
background: var(--accent);
}
.rail-actions {
display: grid;
gap: 4px;
justify-items: center;
}
.rail-btn {
display: inline-flex;
width: 32px;
@@ -605,118 +619,10 @@ button:disabled {
animation: rail-spin 0.9s linear infinite;
}
.nav-drawer nav a.is-active,
.nav-drawer nav a[aria-current='page'] {
color: var(--bg);
background: var(--bg-ink);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.side-stats {
display: grid;
gap: 1px;
border: 1px solid var(--panel-border);
border-radius: 10px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.side-stat {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
background: rgba(0, 0, 0, 0.15);
}
.side-stat span {
color: var(--muted-soft);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.side-stat strong {
color: var(--bg-ink);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.side-refresh {
width: 100%;
min-height: 32px;
padding: 0 10px;
border-radius: 8px;
color: var(--bg-ink);
background: rgba(255, 255, 255, 0.05);
box-shadow: none;
font-size: 12px;
font-weight: 700;
}
.side-refresh:hover {
background: rgba(191, 95, 44, 0.12);
}
.side-install {
width: 100%;
min-height: 32px;
padding: 0 10px;
border-radius: 8px;
background: linear-gradient(135deg, #2b3726, #1c211c);
box-shadow: 0 6px 16px rgba(28, 33, 28, 0.18);
font-size: 12px;
font-weight: 700;
}
.side-pwa,
.pwa-card {
display: grid;
gap: 2px;
min-width: 0;
padding: 7px 10px;
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
}
.side-pwa span,
.pwa-card span {
color: var(--muted-soft);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.side-pwa strong,
.pwa-card strong {
font-size: 12px;
color: var(--bg-ink);
}
.side-pwa.is-online strong,
.pwa-card.is-online strong {
color: var(--green);
}
.side-pwa.is-offline strong,
.pwa-card.is-offline strong {
color: var(--accent);
}
.drawer-pwa-row span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.side-pwa.is-offline,
.pwa-card.is-offline {
border-color: rgba(183, 71, 52, 0.24);
background: rgba(183, 71, 52, 0.08);
.rail-label,
.rail-btn-label,
.rail-foot-label {
display: none;
}
.settings-panel {
@@ -1149,79 +1055,125 @@ button:disabled {
.nav-drawer {
position: fixed;
z-index: 40;
top: max(14px, env(safe-area-inset-top));
bottom: max(14px, env(safe-area-inset-bottom));
left: 14px;
top: max(10px, env(safe-area-inset-top));
bottom: max(10px, env(safe-area-inset-bottom));
left: 10px;
display: grid;
width: min(360px, calc(100vw - 28px));
width: min(324px, calc(100vw - 20px));
grid-template-rows: auto 1fr auto;
gap: 18px;
padding: 18px;
border: 1px solid rgba(255, 250, 240, 0.3);
border-radius: 28px;
background: rgba(255, 250, 240, 0.94);
box-shadow: 0 28px 90px rgba(20, 18, 10, 0.36);
gap: 10px;
padding: 10px;
border: 1px solid var(--panel-border);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 24px 72px rgba(20, 18, 10, 0.34);
backdrop-filter: blur(20px);
}
.drawer-head {
display: flex;
gap: 12px;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.drawer-title {
min-width: 0;
flex: 1;
}
.drawer-head strong {
display: block;
margin-top: 4px;
font-size: 24px;
letter-spacing: -0.04em;
}
.drawer-head button {
min-height: 44px;
padding: 0 14px;
margin-top: 2px;
overflow: hidden;
color: var(--bg-ink);
background: rgba(255, 255, 255, 0.05);
box-shadow: none;
font-size: 15px;
letter-spacing: -0.03em;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-pwa-row {
display: grid;
gap: 8px;
padding-top: 2px;
.nav-drawer .rail-brand {
flex: 0 0 auto;
margin: 0;
}
.drawer-pwa-row button {
min-height: 44px;
padding: 0 14px;
font-size: 13px;
font-weight: 800;
}
.nav-drawer nav {
display: grid;
align-content: start;
gap: 8px;
}
.nav-drawer nav a {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border: 1px solid var(--panel-border);
border-radius: 18px;
color: var(--bg-ink);
background: rgba(255, 255, 255, 0.025);
font-weight: 800;
text-decoration: none;
}
.nav-drawer nav a::after {
.nav-drawer .drawer-close {
width: auto;
min-width: 48px;
padding: 0 12px;
color: var(--muted);
content: '↘';
font-size: 12px;
font-weight: 800;
}
.nav-drawer .rail-nav {
gap: 6px;
justify-items: stretch;
}
.nav-drawer .rail-item {
width: 100%;
height: 48px;
justify-content: flex-start;
gap: 12px;
padding: 0 12px;
border: 1px solid var(--panel-border);
border-radius: 12px;
}
.nav-drawer .rail-item.is-active {
box-shadow: inset 3px 0 0 var(--accent);
}
.nav-drawer .rail-label,
.nav-drawer .rail-btn-label,
.nav-drawer .rail-foot-label {
display: inline;
}
.nav-drawer .rail-label {
overflow: hidden;
color: inherit;
font-size: 14px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-drawer .rail-badge {
position: static;
margin-left: auto;
}
.nav-drawer .rail-foot {
justify-items: stretch;
padding-top: 10px;
}
.nav-drawer .rail-status-line {
padding: 0 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.nav-drawer .rail-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
justify-items: stretch;
}
.nav-drawer .rail-btn {
width: 100%;
min-height: 40px;
gap: 8px;
justify-content: center;
border: 1px solid var(--panel-border);
}
.nav-drawer .rail-btn-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-card,