feat: add Ray-Ban display dashboard PoC (#226)

This commit is contained in:
Eyejoker
2026-06-07 23:05:23 +09:00
committed by GitHub
parent 337d46461c
commit 5c47024e44
5 changed files with 1235 additions and 3 deletions

View File

@@ -0,0 +1,207 @@
import { useEffect, useState } from 'react';
import {
type DashboardInboxAction,
type DashboardOverview,
type StatusSnapshot,
fetchDashboardData,
runInboxAction,
sendRoomMessage,
} from './api';
import { isLocale, matchLocale, type Locale } from './i18n';
import { GlassesPanel } from './GlassesPanel';
import './styles.css';
import './glasses.css';
interface GlassesState {
overview: DashboardOverview;
snapshots: StatusSnapshot[];
}
type InboxItem = DashboardOverview['inbox'][number];
type InboxActionKey = `${string}:${DashboardInboxAction}`;
const REFRESH_INTERVAL_MS = 15_000;
const DASHBOARD_STALE_MS = 75_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
function makeClientRequestId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function readInitialLocale(): Locale {
const stored =
typeof window === 'undefined'
? null
: window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (isLocale(stored)) return stored;
const languages =
typeof navigator === 'undefined'
? []
: [...(navigator.languages || []), navigator.language];
for (const language of languages) {
const matched = matchLocale(language);
if (matched) return matched;
}
return 'en';
}
function dashboardAgeMs(value: string | null | undefined): number | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.max(0, Date.now() - date.getTime());
}
function freshnessLabel(
online: boolean,
generatedAt: string | null | undefined,
): string {
if (!online) return 'offline';
const age = dashboardAgeMs(generatedAt);
if (age !== null && age > DASHBOARD_STALE_MS) return 'stale';
return 'fresh';
}
function readNickname(): string {
if (typeof window === 'undefined') return '';
return window.localStorage.getItem('ejclaw-nickname') ?? '';
}
export function GlassesApp() {
const [data, setData] = useState<GlassesState | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [online, setOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
);
const [locale] = useState<Locale>(readInitialLocale);
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
null,
);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
async function refresh(showSpinner = false) {
if (showSpinner) setRefreshing(true);
try {
const nextData = await fetchDashboardData();
setData({
overview: nextData.overview,
snapshots: nextData.snapshots,
});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
setRefreshing(false);
}
}
async function handleInboxAction(
item: InboxItem,
action: DashboardInboxAction,
) {
const actionKey: InboxActionKey = `${item.id}:${action}`;
setInboxActionKey(actionKey);
try {
await runInboxAction(item.id, action, {
lastOccurredAt: item.lastOccurredAt,
requestId: makeClientRequestId(),
});
await refresh(false);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setInboxActionKey(null);
}
}
async function handleRoomMessage(
roomJid: string,
text: string,
requestId: string,
) {
setRoomMessageKey(roomJid);
try {
await sendRoomMessage(roomJid, text, requestId, readNickname() || null);
void refresh(false);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setRoomMessageKey(null);
}
}
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
useEffect(() => {
function handleOnline() {
setOnline(true);
}
function handleOffline() {
setOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
void refresh();
const id = window.setInterval(() => {
void refresh();
}, REFRESH_INTERVAL_MS);
return () => window.clearInterval(id);
}, []);
if (loading && !data) {
return (
<main className="glasses-shell" aria-busy="true">
<div className="glasses-empty">
<strong>EJClaw</strong>
<span>Loading</span>
</div>
</main>
);
}
if (!data) {
return (
<main className="glasses-shell">
<p className="glasses-error">{error ?? 'Dashboard unavailable'}</p>
</main>
);
}
return (
<GlassesPanel
createRequestId={makeClientRequestId}
error={error}
freshnessText={freshnessLabel(online, data.overview.generatedAt)}
inboxActionKey={inboxActionKey}
locale={locale}
onInboxAction={handleInboxAction}
onRefresh={() => void refresh(true)}
onSendRoomMessage={handleRoomMessage}
overview={data.overview}
refreshing={refreshing}
roomMessageKey={roomMessageKey}
snapshots={data.snapshots}
/>
);
}

View File

@@ -0,0 +1,120 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { GlassesPanel } from './GlassesPanel';
import type { DashboardOverview, StatusSnapshot } from './api';
const overview: DashboardOverview = {
generatedAt: '2026-06-07T13:00:00.000Z',
services: [],
rooms: {
total: 1,
active: 1,
waiting: 0,
inactive: 0,
},
tasks: {
total: 1,
active: 1,
paused: 0,
completed: 0,
watchers: {
active: 0,
paused: 0,
completed: 0,
},
},
usage: {
rows: [],
fetchedAt: null,
},
inbox: [
{
createdAt: '2026-06-07T12:59:30.000Z',
groupKey: 'task:review',
id: 'inbox-1',
kind: 'reviewer-request',
lastOccurredAt: '2026-06-07T12:59:30.000Z',
occurrences: 1,
occurredAt: '2026-06-07T12:59:30.000Z',
roomJid: 'dc:ops',
roomName: 'Ops',
severity: 'warn',
source: 'paired-task',
summary: 'Reviewer requested owner changes',
taskId: 'task-1',
taskStatus: 'active',
title: 'Review follow-up',
},
],
};
const snapshots: StatusSnapshot[] = [
{
agentType: 'codex',
assistantName: 'Codex',
serviceId: 'codex-main',
updatedAt: '2026-06-07T13:00:00.000Z',
entries: [
{
agentType: 'codex',
elapsedMs: 12000,
folder: 'ejclaw',
jid: 'dc:ops',
name: 'Ops',
pendingMessages: false,
pendingTasks: 1,
status: 'processing',
},
],
},
];
describe('GlassesPanel', () => {
it('renders a compact display queue and voice input surface', () => {
const html = renderToStaticMarkup(
createElement(GlassesPanel, {
createRequestId: () => 'req-1',
error: null,
freshnessText: 'fresh',
inboxActionKey: null,
locale: 'ko',
onInboxAction: async () => true,
onRefresh: () => {},
onSendRoomMessage: async () => true,
overview,
refreshing: false,
roomMessageKey: null,
snapshots,
}),
);
expect(html).toContain('glasses-shell');
expect(html).toContain('Review follow-up');
expect(html).toContain('리뷰');
expect(html).toContain('Voice');
});
it('tolerates partial status snapshot payloads while data is refreshing', () => {
const html = renderToStaticMarkup(
createElement(GlassesPanel, {
createRequestId: () => 'req-1',
error: null,
freshnessText: 'fresh',
inboxActionKey: null,
locale: 'ko',
onInboxAction: async () => true,
onRefresh: () => {},
onSendRoomMessage: async () => true,
overview,
refreshing: false,
roomMessageKey: null,
snapshots: [{} as StatusSnapshot],
}),
);
expect(html).toContain('glasses-shell');
expect(html).toContain('Review follow-up');
});
});

View File

@@ -0,0 +1,607 @@
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Check, Inbox, Mic, RefreshCw, Send, X } from 'lucide-react';
import {
type DashboardInboxAction,
type DashboardOverview,
type StatusSnapshot,
} from './api';
import { formatDate } from './dashboardHelpers';
import type { Locale } from './i18n';
type InboxItem = DashboardOverview['inbox'][number];
type GlassesMode = 'queue' | 'voice';
interface RoomChoice {
jid: string;
label: string;
status: StatusSnapshot['entries'][number]['status'];
pendingTasks: number;
}
interface SpeechRecognitionResultLike {
readonly 0?: { transcript?: string };
}
interface SpeechRecognitionEventLike {
readonly results: ArrayLike<SpeechRecognitionResultLike>;
}
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onend: (() => void) | null;
onerror: (() => void) | null;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
start: () => void;
}
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
interface SpeechWindow extends Window {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}
export interface GlassesPanelProps {
createRequestId: () => string;
error: string | null;
freshnessText: string;
inboxActionKey: `${string}:${DashboardInboxAction}` | null;
locale: Locale;
onInboxAction: (
item: InboxItem,
action: DashboardInboxAction,
) => Promise<boolean>;
onRefresh: () => void;
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
overview: DashboardOverview;
refreshing: boolean;
roomMessageKey: string | null;
snapshots: StatusSnapshot[];
}
const QUEUE_KIND_ORDER: Record<InboxItem['kind'], number> = {
approval: 0,
'reviewer-request': 1,
'arbiter-request': 2,
'ci-failure': 3,
mention: 4,
'pending-room': 5,
};
const SEVERITY_ORDER: Record<InboxItem['severity'], number> = {
error: 0,
warn: 1,
info: 2,
};
const SPEECH_LOCALES: Record<Locale, string> = {
en: 'en-US',
ja: 'ja-JP',
ko: 'ko-KR',
zh: 'zh-CN',
};
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
if (typeof window === 'undefined') return null;
const speechWindow = window as SpeechWindow;
return (
speechWindow.SpeechRecognition ??
speechWindow.webkitSpeechRecognition ??
null
);
}
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
if (
item.source === 'paired-task' &&
(item.kind === 'reviewer-request' ||
item.kind === 'approval' ||
item.kind === 'arbiter-request')
) {
return ['run', 'decline', 'dismiss'];
}
return ['dismiss'];
}
function actionLabel(item: InboxItem, action: DashboardInboxAction): string {
if (action === 'dismiss') return '닫기';
if (action === 'decline') return '거절';
if (item.kind === 'approval') return '최종화';
if (item.kind === 'reviewer-request') return '리뷰';
if (item.kind === 'arbiter-request') return '중재';
return '실행';
}
function actionIcon(action: DashboardInboxAction) {
if (action === 'decline') return <X size={16} aria-hidden />;
if (action === 'dismiss') return <Check size={16} aria-hidden />;
return <Send size={16} aria-hidden />;
}
function sortInboxItems(items: InboxItem[]): InboxItem[] {
return [...items].sort((a, b) => {
const severity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
if (severity !== 0) return severity;
const kind = QUEUE_KIND_ORDER[a.kind] - QUEUE_KIND_ORDER[b.kind];
if (kind !== 0) return kind;
return b.lastOccurredAt.localeCompare(a.lastOccurredAt);
});
}
function buildRoomChoices(snapshots: StatusSnapshot[]): RoomChoice[] {
const rooms = new Map<string, RoomChoice>();
for (const snapshot of snapshots) {
const entries = Array.isArray(snapshot.entries) ? snapshot.entries : [];
for (const entry of entries) {
const existing = rooms.get(entry.jid);
const label = entry.name || entry.folder || entry.jid;
if (!existing) {
rooms.set(entry.jid, {
jid: entry.jid,
label,
pendingTasks: entry.pendingTasks,
status: entry.status,
});
continue;
}
if (
entry.status === 'processing' ||
entry.pendingTasks > existing.pendingTasks
) {
rooms.set(entry.jid, {
jid: entry.jid,
label,
pendingTasks: entry.pendingTasks,
status: entry.status,
});
}
}
}
return [...rooms.values()].sort((a, b) => {
if (a.status === 'processing' && b.status !== 'processing') return -1;
if (b.status === 'processing' && a.status !== 'processing') return 1;
return b.pendingTasks - a.pendingTasks || a.label.localeCompare(b.label);
});
}
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.max(0, Math.min(index, length - 1));
}
function nextIndex(index: number, length: number, delta: number): number {
if (length <= 0) return 0;
return (index + delta + length) % length;
}
function readTranscript(event: SpeechRecognitionEventLike): string {
return Array.from(event.results)
.map((result) => result[0]?.transcript?.trim() ?? '')
.filter(Boolean)
.join(' ')
.trim();
}
interface QueueCardProps {
inboxActionKey: `${string}:${DashboardInboxAction}` | null;
inboxActions: DashboardInboxAction[];
inboxItems: InboxItem[];
locale: Locale;
onInboxAction: (
item: InboxItem,
action: DashboardInboxAction,
) => Promise<boolean>;
selectedActionIndex: number;
selectedInbox: InboxItem | undefined;
selectedInboxIndex: number;
setSelectedActionIndex: (index: number) => void;
}
function QueueCard({
inboxActionKey,
inboxActions,
inboxItems,
locale,
onInboxAction,
selectedActionIndex,
selectedInbox,
selectedInboxIndex,
setSelectedActionIndex,
}: QueueCardProps) {
if (!selectedInbox) {
return (
<section className="glasses-card glasses-queue-card" aria-live="polite">
<div className="glasses-empty">
<strong>Queue clear</strong>
<span> </span>
</div>
</section>
);
}
return (
<section className="glasses-card glasses-queue-card" aria-live="polite">
<div className="glasses-card-head">
<span className={`glasses-pill sev-${selectedInbox.severity}`}>
{selectedInbox.kind}
</span>
<small>
{selectedInboxIndex + 1}/{inboxItems.length}
</small>
</div>
<h2>{selectedInbox.title}</h2>
<p>{selectedInbox.summary}</p>
<dl className="glasses-meta">
<div>
<dt>Target</dt>
<dd>
{selectedInbox.roomName ??
selectedInbox.groupFolder ??
selectedInbox.roomJid ??
selectedInbox.taskId ??
'-'}
</dd>
</div>
<div>
<dt>Time</dt>
<dd>{formatDate(selectedInbox.lastOccurredAt, locale)}</dd>
</div>
</dl>
<div className="glasses-actions">
{inboxActions.map((action, index) => {
const actionKey = `${selectedInbox.id}:${action}`;
const busy = inboxActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
aria-pressed={index === selectedActionIndex}
className={
index === selectedActionIndex ? 'is-active' : undefined
}
disabled={busy || Boolean(inboxActionKey)}
key={action}
onClick={() => {
setSelectedActionIndex(index);
void onInboxAction(selectedInbox, action);
}}
type="button"
>
{actionIcon(action)}
{busy ? '처리 중' : actionLabel(selectedInbox, action)}
</button>
);
})}
</div>
</section>
);
}
interface VoiceCardProps {
canListen: boolean;
listening: boolean;
onSendVoiceText: () => void;
onStartListening: () => void;
selectedRoom: RoomChoice | undefined;
sendingVoice: boolean;
setSelectedRoomIndex: (index: number) => void;
setVoiceText: (value: string) => void;
rooms: RoomChoice[];
voiceText: string;
}
function VoiceCard({
canListen,
listening,
onSendVoiceText,
onStartListening,
selectedRoom,
sendingVoice,
setSelectedRoomIndex,
setVoiceText,
rooms,
voiceText,
}: VoiceCardProps) {
return (
<section className="glasses-card glasses-voice-card">
<div className="glasses-card-head">
<span className="glasses-pill">voice</span>
<small>{selectedRoom?.label ?? 'No room'}</small>
</div>
<textarea
aria-label="음성 또는 키보드 입력"
onChange={(event) => setVoiceText(event.target.value)}
placeholder="말하거나 입력..."
value={voiceText}
/>
<div className="glasses-room-strip" aria-label="room target">
{rooms.slice(0, 4).map((room, index) => (
<button
aria-pressed={room.jid === selectedRoom?.jid}
className={room.jid === selectedRoom?.jid ? 'is-active' : undefined}
key={room.jid}
onClick={() => setSelectedRoomIndex(index)}
type="button"
>
{room.label}
</button>
))}
</div>
<div className="glasses-actions">
<button
disabled={!canListen || listening}
onClick={onStartListening}
type="button"
>
<Mic size={16} aria-hidden />
{listening ? '듣는 중' : '말하기'}
</button>
<button
className="is-active"
disabled={!selectedRoom || !voiceText.trim() || sendingVoice}
onClick={onSendVoiceText}
type="button"
>
<Send size={16} aria-hidden />
{sendingVoice ? '전송 중' : '전송'}
</button>
</div>
</section>
);
}
interface GlassesHeaderProps {
onRefresh: () => void;
refreshing: boolean;
}
function GlassesHeader({ onRefresh, refreshing }: GlassesHeaderProps) {
return (
<header className="glasses-header">
<div>
<span className="glasses-kicker">EJClaw</span>
<h1>Display</h1>
</div>
<button
aria-busy={refreshing || undefined}
aria-label="새로고침"
className="glasses-icon-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
<RefreshCw size={18} aria-hidden />
</button>
</header>
);
}
interface ModeTabsProps {
mode: GlassesMode;
setMode: (mode: GlassesMode) => void;
}
function ModeTabs({ mode, setMode }: ModeTabsProps) {
return (
<nav className="glasses-tabs" aria-label="display modes">
<button
aria-pressed={mode === 'queue'}
className={mode === 'queue' ? 'is-active' : undefined}
onClick={() => setMode('queue')}
type="button"
>
<Inbox size={16} aria-hidden />
Queue
</button>
<button
aria-pressed={mode === 'voice'}
className={mode === 'voice' ? 'is-active' : undefined}
onClick={() => setMode('voice')}
type="button"
>
<Mic size={16} aria-hidden />
Voice
</button>
</nav>
);
}
function footerStatus(
mode: GlassesMode,
busyActionKey: string | null,
selectedRoom: RoomChoice | undefined,
): string {
if (mode === 'queue') return busyActionKey ?? 'ready';
return selectedRoom?.status ?? 'ready';
}
export function GlassesPanel({
createRequestId,
error,
freshnessText,
inboxActionKey,
locale,
onInboxAction,
onRefresh,
onSendRoomMessage,
overview,
refreshing,
roomMessageKey,
snapshots,
}: GlassesPanelProps) {
const shellRef = useRef<HTMLElement | null>(null);
const [mode, setMode] = useState<GlassesMode>('queue');
const [selectedInboxIndex, setSelectedInboxIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [selectedRoomIndex, setSelectedRoomIndex] = useState(0);
const [voiceText, setVoiceText] = useState('');
const [listening, setListening] = useState(false);
const inboxItems = useMemo(
() => sortInboxItems(Array.isArray(overview.inbox) ? overview.inbox : []),
[overview.inbox],
);
const rooms = useMemo(
() => buildRoomChoices(Array.isArray(snapshots) ? snapshots : []),
[snapshots],
);
const selectedInbox =
inboxItems[clampIndex(selectedInboxIndex, inboxItems.length)];
const inboxActions = selectedInbox ? inboxActionsFor(selectedInbox) : [];
const selectedAction =
inboxActions[clampIndex(selectedActionIndex, inboxActions.length)];
const selectedRoom = rooms[clampIndex(selectedRoomIndex, rooms.length)];
const canListen = getSpeechRecognition() !== null;
const busyActionKey =
selectedInbox && selectedAction
? `${selectedInbox.id}:${selectedAction}`
: null;
const sendingVoice = selectedRoom
? roomMessageKey === selectedRoom.jid
: false;
useEffect(() => {
shellRef.current?.focus();
}, []);
useEffect(() => {
setSelectedInboxIndex((value) => clampIndex(value, inboxItems.length));
}, [inboxItems.length]);
useEffect(() => {
setSelectedActionIndex((value) => clampIndex(value, inboxActions.length));
}, [inboxActions.length]);
useEffect(() => {
setSelectedRoomIndex((value) => clampIndex(value, rooms.length));
}, [rooms.length]);
function startListening() {
const SpeechRecognition = getSpeechRecognition();
if (!SpeechRecognition || listening) return;
const recognition = new SpeechRecognition();
recognition.lang = SPEECH_LOCALES[locale];
recognition.continuous = false;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
const nextText = readTranscript(event);
if (nextText) setVoiceText(nextText);
};
recognition.onerror = () => setListening(false);
recognition.onend = () => setListening(false);
setListening(true);
recognition.start();
}
async function runSelectedAction() {
if (!selectedInbox || !selectedAction || inboxActionKey) return;
await onInboxAction(selectedInbox, selectedAction);
}
async function sendVoiceText() {
const text = voiceText.trim();
if (!selectedRoom || !text || sendingVoice) return;
const ok = await onSendRoomMessage(
selectedRoom.jid,
text,
createRequestId(),
);
if (ok) setVoiceText('');
}
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
setMode((value) => (value === 'queue' ? 'voice' : 'queue'));
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? 1 : -1;
if (mode === 'queue') {
setSelectedInboxIndex((value) =>
nextIndex(value, inboxItems.length, delta),
);
} else {
setSelectedRoomIndex((value) => nextIndex(value, rooms.length, delta));
}
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (mode === 'queue') void runSelectedAction();
else void sendVoiceText();
}
}
return (
<main
aria-label="EJClaw Ray-Ban Display"
className="glasses-shell"
onKeyDown={handleKeyDown}
ref={shellRef}
tabIndex={0}
>
<GlassesHeader onRefresh={onRefresh} refreshing={refreshing} />
<section className="glasses-status-row" aria-label="상태">
<span>{freshnessText}</span>
<strong>{inboxItems.length} items</strong>
</section>
{error ? <p className="glasses-error">{error}</p> : null}
<ModeTabs mode={mode} setMode={setMode} />
{mode === 'queue' ? (
<QueueCard
inboxActionKey={inboxActionKey}
inboxActions={inboxActions}
inboxItems={inboxItems}
locale={locale}
onInboxAction={onInboxAction}
selectedActionIndex={selectedActionIndex}
selectedInbox={selectedInbox}
selectedInboxIndex={selectedInboxIndex}
setSelectedActionIndex={setSelectedActionIndex}
/>
) : (
<VoiceCard
canListen={canListen}
listening={listening}
onSendVoiceText={() => void sendVoiceText()}
onStartListening={startListening}
rooms={rooms}
selectedRoom={selectedRoom}
sendingVoice={sendingVoice}
setSelectedRoomIndex={setSelectedRoomIndex}
setVoiceText={setVoiceText}
voiceText={voiceText}
/>
)}
<footer className="glasses-footer">
<span>{footerStatus(mode, busyActionKey, selectedRoom)}</span>
</footer>
</main>
);
}

View File

@@ -0,0 +1,293 @@
body:has(.glasses-shell) {
min-width: 0;
overflow: hidden;
background: #000;
}
.glasses-shell {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
gap: 8px;
width: min(100vw, 600px);
height: min(100vh, 600px);
min-height: 0;
margin: 0 auto;
padding: 14px;
overflow: hidden;
background: #050706;
color: #f4f1e9;
}
.glasses-header,
.glasses-status-row,
.glasses-card-head,
.glasses-actions,
.glasses-footer,
.glasses-tabs,
.glasses-room-strip {
display: flex;
align-items: center;
}
.glasses-header {
justify-content: space-between;
gap: 12px;
}
.glasses-kicker,
.glasses-status-row,
.glasses-card-head small,
.glasses-meta dt,
.glasses-footer {
color: #a8afa6;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.glasses-header h1 {
margin: 0;
font-size: 26px;
line-height: 1;
}
.glasses-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
border-radius: 8px;
}
.glasses-status-row {
justify-content: space-between;
gap: 8px;
}
.glasses-status-row strong {
color: #6cb087;
}
.glasses-error {
margin: 0;
padding: 8px 10px;
border: 1px solid rgba(224, 100, 75, 0.45);
border-radius: 8px;
background: rgba(224, 100, 75, 0.12);
color: #ffb4a4;
font-size: 12px;
}
.glasses-tabs {
gap: 6px;
min-width: 0;
}
.glasses-tabs button,
.glasses-actions button,
.glasses-room-strip button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #f4f1e9;
white-space: nowrap;
}
.glasses-tabs button:hover:not(:disabled),
.glasses-actions button:hover:not(:disabled),
.glasses-room-strip button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.glasses-tabs button {
flex: 1;
}
.glasses-tabs button.is-active,
.glasses-tabs button[aria-pressed='true'],
.glasses-actions button.is-active,
.glasses-actions button[aria-pressed='true'],
.glasses-room-strip button.is-active,
.glasses-room-strip button[aria-pressed='true'] {
border-color: rgba(232, 152, 112, 0.72);
background: rgba(214, 130, 88, 0.2);
color: #fff4ec;
}
.glasses-tabs button.is-active:hover:not(:disabled),
.glasses-tabs button[aria-pressed='true']:hover:not(:disabled),
.glasses-actions button.is-active:hover:not(:disabled),
.glasses-actions button[aria-pressed='true']:hover:not(:disabled),
.glasses-room-strip button.is-active:hover:not(:disabled),
.glasses-room-strip button[aria-pressed='true']:hover:not(:disabled) {
background: rgba(214, 130, 88, 0.26);
}
.glasses-card {
display: grid;
align-content: start;
gap: 10px;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: #111512;
padding: 12px;
}
.glasses-card-head {
justify-content: space-between;
gap: 10px;
}
.glasses-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 24px;
padding: 0 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: #d7ded2;
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.glasses-pill.sev-error {
border-color: rgba(224, 100, 75, 0.5);
color: #ffb4a4;
}
.glasses-pill.sev-warn {
border-color: rgba(212, 160, 74, 0.5);
color: #ffd38b;
}
.glasses-queue-card h2 {
margin: 0;
overflow: hidden;
font-size: 22px;
line-height: 1.08;
text-overflow: ellipsis;
white-space: nowrap;
}
.glasses-queue-card p {
display: -webkit-box;
min-height: 0;
margin: 0;
overflow: hidden;
color: #d2d5ce;
font-size: 14px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.glasses-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0;
}
.glasses-meta div {
min-width: 0;
}
.glasses-meta dt,
.glasses-meta dd {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.glasses-meta dd {
margin: 2px 0 0;
color: #f4f1e9;
font-size: 13px;
}
.glasses-actions {
gap: 6px;
min-width: 0;
}
.glasses-actions button {
flex: 1;
min-width: 0;
padding: 0 10px;
}
.glasses-empty {
display: grid;
place-items: center;
gap: 6px;
min-height: 220px;
color: #a8afa6;
text-align: center;
}
.glasses-empty strong {
color: #f4f1e9;
font-size: 24px;
}
.glasses-voice-card textarea {
width: 100%;
min-height: 126px;
resize: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #f4f1e9;
font: inherit;
font-size: 18px;
line-height: 1.35;
padding: 10px;
}
.glasses-voice-card textarea:focus-visible {
border-color: #d68258;
outline: 2px solid rgba(214, 130, 88, 0.2);
}
.glasses-room-strip {
gap: 6px;
overflow: hidden;
}
.glasses-room-strip button {
min-width: 0;
max-width: 50%;
overflow: hidden;
padding: 0 10px;
text-overflow: ellipsis;
}
.glasses-footer {
min-height: 18px;
justify-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.glasses-shell {
width: 100vw;
height: 100vh;
}
}

View File

@@ -2,6 +2,13 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import { GlassesApp } from './GlassesApp';
function isGlassesRoute(): boolean {
const pathname = window.location.pathname.replace(/\/+$/, '') || '/';
const search = new URLSearchParams(window.location.search);
return pathname === '/glasses' || search.get('display') === 'rayban';
}
const root = document.getElementById('root'); const root = document.getElementById('root');
@@ -10,7 +17,5 @@ if (!root) {
} }
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>{isGlassesRoute() ? <GlassesApp /> : <App />}</StrictMode>,
<App />
</StrictMode>,
); );