refactor(frontend): migrate page layout and transcripts to Tailwind

This commit is contained in:
2026-10-07 09:58:51 -03:00
parent 264d7905c6
commit f3bae8ae0c
8 changed files with 279 additions and 112 deletions
+13 -5
View File
@@ -20,21 +20,29 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
return null;
}
return (
<div className={`bubble ${message.role} in`}>
<div className="bubble-role">
<div
className={`pointer-events-auto max-w-full rounded-panel border border-solid border-line bg-surface-raised px-3.5 py-2.5 text-[14px] leading-[1.5] wrap-anywhere shadow-[0_4px_18px_rgb(0_0_0/0.32)] compact:text-[13px] ${message.role === "assistant" ? "self-end" : "self-start"} ${message.role === "tool" ? "flex items-center gap-2 font-mono text-[13px] text-muted" : ""}`}
>
<div
className={`mb-1 font-mono text-[11px] font-medium tracking-[0.1em] uppercase opacity-70 compact:text-[10px] ${message.role === "user" ? "text-voice-user" : message.role === "assistant" ? "text-voice-assistant" : "text-voice-tool"}`}
>
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
</div>
{message.role === "assistant" ? (
<AssistantMarkdown text={message.text} className="bubble-body" />
<AssistantMarkdown text={message.text} className="max-h-60 overflow-auto" />
) : (
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
<div className="max-h-60 overflow-auto">{message.role === "tool" ? message.toolName : message.text}</div>
)}
</div>
);
}
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
return (
<div className="bubble-stack" aria-hidden="true" inert>
<div
className="pointer-events-none fixed top-24 right-7 z-100 flex w-[min(300px,calc(100vw-56px))] flex-col gap-2 compact:hidden"
aria-hidden="true"
inert
>
{messages.slice(-3).map((message) => (
<Bubble key={message.id} message={message} userName={userName} />
))}
+85 -40
View File
@@ -1,4 +1,4 @@
import { ArrowDown, Download, MessageCircle, Wrench } from "lucide-react";
import { ArrowDown, Bot, Download, MessageCircle, Wrench } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import { initials } from "../lib/context";
@@ -6,49 +6,84 @@ import type { ConnectionStatus } from "../lib/contracts";
import { displayTime, exportConversation, type Message } from "../lib/messages";
import { LIVE_STATES } from "../realtime/controller";
import { AssistantMarkdown } from "./AssistantMarkdown";
import { Button, IconButton } from "./ui";
const messagePositions = {
user: "mr-auto self-start white:my-1 white:mr-10 white:ml-auto white:self-end",
assistant: "ml-auto self-end white:my-1 white:mr-auto white:ml-10 white:self-start",
};
const messageSurfaces = {
user: "border-(--message-user-border) [background:var(--message-user-background)] shadow-(--message-user-shadow) white:rounded-br-md",
assistant:
"border-(--message-assistant-border) [background:var(--message-assistant-background)] shadow-(--message-assistant-shadow) white:rounded-bl-md",
};
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
const [expanded, setExpanded] = useState(false);
const time = displayTime(message.timestamp);
if (message.role === "tool") {
return (
<div className="hist-msg tool">
<div className="hist-role">Llamada a herramienta</div>
<div className="flex max-w-full min-w-0 shrink-0 flex-col gap-1.5 self-start white:my-1">
<div className="px-1 font-mono text-[11px] font-bold tracking-[0.14em] text-voice-tool uppercase compact:text-[10px]">
Llamada a herramienta
</div>
<button
className="hist-tool-header"
className="flex w-full cursor-pointer items-center gap-2 rounded-t-panel border border-solid border-processing/25 bg-surface-overlay px-3 py-2 text-left font-sans text-[14px] font-medium text-foreground hover:bg-processing/15 focus-visible:outline-2 focus-visible:outline-accent compact:text-[13px]"
type="button"
aria-expanded={expanded}
onClick={() => {
setExpanded(!expanded);
}}
>
<Wrench className="hist-tool-icon" />
<span className="hist-tool-name">{message.toolName}</span>
<Wrench className="size-[13px] shrink-0 text-processing" />
<span className="font-mono text-[13px] font-semibold text-voice-tool compact:text-[12px]">
{message.toolName}
</span>
</button>
<div className={`hist-tool-body ${expanded ? "open" : ""}`}>
<div className="hist-tool-label">Entrada</div>
<pre className="hist-tool-block">{message.toolArguments}</pre>
<div className="hist-tool-label">Salida</div>
<pre className="hist-tool-block hist-tool-output">{message.text}</pre>
</div>
{expanded && (
<div className="rounded-b-panel border border-t-0 border-solid border-processing/25 bg-surface px-3 pt-2.5 pb-3">
<div className="mb-1 font-mono text-[10px] tracking-[0.08em] text-faint uppercase">Entrada</div>
<pre className="m-0 overflow-x-auto font-mono text-[12px] leading-[1.65] wrap-anywhere whitespace-pre-wrap text-muted compact:text-[11px]">
{message.toolArguments}
</pre>
<div className="mt-2 mb-1 font-mono text-[10px] tracking-[0.08em] text-faint uppercase">Salida</div>
<pre className="m-0 overflow-x-auto font-mono text-[12px] leading-[1.65] wrap-anywhere whitespace-pre-wrap text-foreground compact:text-[11px]">
{message.text}
</pre>
</div>
)}
</div>
);
}
const bodyClass = `min-w-0 rounded-[14px] border border-solid px-3.5 py-[11px] text-[14px] leading-[1.55] wrap-anywhere text-foreground backdrop-blur-[8px] backdrop-saturate-130 white:rounded-[18px] white:px-[15px] white:py-3 white:text-[clamp(14.5px,3.3vw,16.5px)] white:leading-[1.45] white:tracking-[-0.005em] white:backdrop-filter-none ${messageSurfaces[message.role]} ${message.interrupted ? "opacity-70" : message.partial ? "opacity-68 white:opacity-92" : ""}`;
return (
<div
className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`}
data-user-initials={initials(userName)}
className={`relative flex max-w-[86%] min-w-0 shrink-0 flex-col gap-1.5 white:max-w-[calc(100%-40px)] ${messagePositions[message.role]}`}
>
<div className="hist-meta">
<span>{message.role === "user" ? userName : "Vocero"}</span>
<time dateTime={message.timestamp}>{time}</time>
<span
aria-hidden="true"
className={`absolute top-[22px] z-2 hidden size-7 items-center justify-center rounded-full border border-solid text-[11px] font-bold tracking-[-0.01em] text-white shadow-[0_2px_8px_rgb(15_23_42/0.1)] white:inline-flex ${message.role === "user" ? "-right-10 border-brand-tertiary/35 bg-linear-to-br from-brand-secondary to-brand-tertiary" : "-left-10 border-brand-secondary/25 bg-accent"}`}
>
{message.role === "user" ? initials(userName) : <Bot size={20} strokeWidth={1.8} />}
</span>
<div
className={`flex flex-wrap items-baseline gap-x-2.5 gap-y-1 text-[12px] text-muted white:text-slate-500 ${message.role === "user" ? "white:justify-end" : ""}`}
>
<span className="font-semibold wrap-anywhere">{message.role === "user" ? userName : "Vocero"}</span>
<time className="whitespace-nowrap" dateTime={message.timestamp}>
{time}
</time>
</div>
{message.role === "assistant" ? (
<AssistantMarkdown text={message.text} className={`hist-body ${message.partial ? "partial" : ""}`} />
<AssistantMarkdown text={message.text} className={bodyClass} />
) : (
<div className={`hist-body ${message.partial ? "partial" : ""}`}>{message.text}</div>
<div className={`${bodyClass} whitespace-pre-wrap`}>{message.text}</div>
)}
{message.interrupted && (
<div className="self-end px-0.5 font-mono text-[10px] font-medium tracking-[0.1em] text-faint uppercase">
Interrumpido
</div>
)}
{message.interrupted && <div className="hist-note">Interrumpido</div>}
</div>
);
});
@@ -77,26 +112,35 @@ export const Conversation = memo(function Conversation({
const live = LIVE_STATES.has(status);
const waiting = ["connecting", "queued", "your-turn"].includes(status);
return (
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
<div className="chat-panel-inner">
<header className="chat-panel-header">
<h2>Conversación</h2>
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
<section
id="chat-panel"
className="relative z-1 mx-3 mt-2 flex h-[clamp(240px,45dvh,640px)] min-w-0 flex-[1_0_auto] flex-col white:rounded-[20px] white:border white:border-solid white:border-line white:bg-surface white:shadow-panel compact:mx-0"
aria-label="Transcripción de la conversación"
>
<div className="relative mx-auto flex h-full min-h-0 w-full max-w-[920px] flex-col overflow-hidden p-3 compact:p-2.5">
<header className="flex flex-none flex-wrap items-center justify-between gap-2 pb-2.5 white:border-0 white:border-b white:border-solid white:border-line">
<h2 className="m-0 min-w-0 bg-(image:--brand-gradient) bg-clip-text text-[15px] font-extrabold tracking-[0.04em] text-transparent uppercase opacity-92 white:bg-none white:text-[clamp(18px,4.3vw,22px)] white:font-bold white:tracking-[-0.01em] white:text-foreground white:normal-case white:opacity-100 compact:text-[13px] white:compact:text-[17px]">
Conversación
</h2>
<div
className="inline-flex min-w-0 flex-none flex-wrap items-center justify-end gap-1.5"
role="group"
aria-label="Acciones de la conversación"
>
{white && (
<span
className="chat-status-pill"
className="inline-flex flex-none items-center gap-[7px] rounded-full bg-slate-100 py-[5px] pr-[11px] pl-2 text-[12.5px] font-semibold text-slate-600 data-[status=error]:bg-red-50 data-[status=error]:text-red-700 data-[status=online]:bg-emerald-50 data-[status=online]:text-green-700 data-[status=waiting]:bg-amber-50 data-[status=waiting]:text-amber-900 compact:px-[7px] compact:text-[11px]"
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
role="status"
>
<span className="chat-status-dot" aria-hidden="true" />
<span className="block size-[9px] flex-none rounded-full bg-current" aria-hidden="true" />
<span>
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
</span>
</span>
)}
<button
className="chat-export-btn icon-btn"
type="button"
<IconButton
round
aria-label="Descargar conversación"
title="Descargar conversación (JSON + TXT)"
disabled={!messages.length}
@@ -105,13 +149,13 @@ export const Conversation = memo(function Conversation({
}}
>
<Download size={18} />
</button>
</IconButton>
</div>
</header>
<div
ref={history}
id="chat-history"
className="chat-history"
className="flex min-h-0 flex-1 [scrollbar-width:thin] flex-col gap-3.5 overflow-x-hidden overflow-y-auto overscroll-contain px-1 py-3 [overflow-anchor:none] focus-visible:rounded-lg focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
role="log"
tabIndex={0}
aria-label="Mensajes de la conversación"
@@ -128,10 +172,12 @@ export const Conversation = memo(function Conversation({
{messages.length ? (
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
) : (
<div className="chat-empty">
<MessageCircle aria-hidden="true" />
<span className="chat-empty-title">Aún no hay mensajes</span>
<span className="chat-empty-hint">
<div className="flex flex-1 flex-col items-center justify-center gap-1.5 py-12 text-muted white:text-slate-500">
<MessageCircle className="mb-1.5 opacity-80" aria-hidden="true" />
<span className="font-mono text-[12px] font-medium tracking-[0.14em] uppercase compact:text-[11px]">
Aún no hay mensajes
</span>
<span className="text-[13px] compact:text-[12px]">
{muted
? "Activa el micrófono para hablar"
: live
@@ -144,9 +190,8 @@ export const Conversation = memo(function Conversation({
)}
</div>
{showLatest && (
<button
className="jump-to-latest btn secondary"
type="button"
<Button
className="absolute right-[18px] bottom-[18px] z-2 inline-flex items-center gap-1.5 shadow-panel"
onClick={() => {
if (history.current) {
history.current.scrollTop = history.current.scrollHeight;
@@ -158,7 +203,7 @@ export const Conversation = memo(function Conversation({
>
<ArrowDown size={16} aria-hidden="true" />
Ir al último mensaje
</button>
</Button>
)}
</div>
</section>
@@ -5,8 +5,8 @@ import { Dialog } from "./Dialog";
function ReportContent({ reports }: { reports: Reports }) {
const suggestions = reportSuggestions(reports);
return (
<div className="reports-content">
<div className="reports-hint">
<div>
<div className="report-art relative m-0 max-w-full rounded-control border border-solid p-3 text-[12px] leading-[1.3] text-muted">
<p>{reports.count} reportes disponibles</p>
<ul>
{reports.hints.map((hint, index) => (
@@ -16,9 +16,9 @@ function ReportContent({ reports }: { reports: Reports }) {
{reports.note && <p>{reports.note}</p>}
</div>
{suggestions.length > 0 && (
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
<div>
<h3>Puedes preguntar</h3>
<ul>
{suggestions.map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
@@ -41,8 +41,12 @@ export function ReportsPanel({
}) {
if (desktop) {
return (
<aside id="reports-sidebar" className="reports-panel" aria-label="Reportes programados">
<h2>Reportes</h2>
<aside
id="reports-sidebar"
className="fixed inset-y-0 left-0 z-3 w-[320px] overflow-y-auto border-0 border-r border-solid border-line bg-surface-raised p-5"
aria-label="Reportes programados"
>
<h2 className="mt-0 mb-4 text-[18px]">Reportes</h2>
<ReportContent reports={reports} />
</aside>
);
@@ -4,7 +4,7 @@ import type { VoiceController } from "../realtime/controller";
function WakeWordStatus({ controller }: { controller: VoiceController }) {
const status = useWakeWord(true, controller);
return (
<span role="status">
<span className="text-[12px] text-muted white:text-slate-500" role="status">
{status === "listening"
? "Micrófono activo: escuchando la frase de activación."
: status === "blocked"
@@ -18,8 +18,8 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
return null;
}
return (
<div className="wake-word-control">
<label>
<div className="grid gap-1 px-4 pb-3 text-center text-[13px]">
<label className="flex cursor-pointer items-center justify-center gap-2">
<input
type="checkbox"
checked={enabled}
@@ -32,7 +32,9 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
{enabled ? (
<WakeWordStatus controller={controller} />
) : (
<span role="status">Opcional. Se desactiva al detener la conversación.</span>
<span className="text-[12px] text-muted white:text-slate-500" role="status">
Opcional. Se desactiva al detener la conversación.
</span>
)}
</div>
);