refactor(frontend): share Tailwind dialog and form controls

This commit is contained in:
2026-10-07 09:55:57 -03:00
parent e9808cb12b
commit 264d7905c6
4 changed files with 235 additions and 109 deletions
+21 -8
View File
@@ -1,13 +1,22 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useLayoutEffect, useId, useRef, type ReactNode } from "react"; import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
import { IconButton } from "./ui";
interface Props { interface Props {
title: string; title: string;
children: ReactNode; children: ReactNode;
onClose: () => void; onClose: () => void;
className?: string; size?: "default" | "about" | "profile" | "drawer";
} }
export function Dialog({ title, children, onClose, className = "" }: Props) { const sizes = {
default: "w-[min(440px,92vw)] rounded-panel",
about: "max-h-[88vh] w-[min(680px,92vw)] rounded-panel",
profile: "max-h-[90vh] w-[min(480px,90vw)] rounded-panel",
drawer: "m-0 h-dvh max-h-dvh w-[min(360px,92vw)] rounded-none",
};
export function Dialog({ title, children, onClose, size = "default" }: Props) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
const titleId = useId(); const titleId = useId();
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -24,7 +33,7 @@ export function Dialog({ title, children, onClose, className = "" }: Props) {
return ( return (
<dialog <dialog
ref={ref} ref={ref}
className={`modal ${className}`} className={`border border-solid border-line bg-surface-raised p-0 text-foreground shadow-panel backdrop:bg-[rgb(8_9_13/0.65)] backdrop:backdrop-blur-[4px] ${sizes[size]}`}
aria-labelledby={titleId} aria-labelledby={titleId}
onCancel={(event) => { onCancel={(event) => {
event.preventDefault(); event.preventDefault();
@@ -36,12 +45,16 @@ export function Dialog({ title, children, onClose, className = "" }: Props) {
} }
}} }}
> >
<div className="modal-content"> <div
<header className="modal-header"> className={`flex flex-col white:border white:border-solid white:border-line white:bg-surface white:text-foreground ${size === "about" ? "max-h-[88vh] gap-5 overflow-y-auto px-[30px] pt-[26px] pb-6" : "gap-4 px-6 pt-[22px] pb-5"}`}
<h2 id={titleId}>{title}</h2> >
<button type="button" className="icon-btn" aria-label="Cerrar" onClick={onClose}> <header className="mb-1 flex items-center justify-between gap-3">
<h2 className="m-0 text-[17px] font-semibold tracking-[0.02em] compact:text-[16px]" id={titleId}>
{title}
</h2>
<IconButton aria-label="Cerrar" onClick={onClose}>
<X size={18} /> <X size={18} />
</button> </IconButton>
</header> </header>
{children} {children}
</div> </div>
@@ -48,7 +48,7 @@ export function ReportsPanel({
); );
} }
return open ? ( return open ? (
<Dialog title="Reportes" className="reports-drawer" onClose={onClose}> <Dialog title="Reportes" size="drawer" onClose={onClose}>
<div id="reports-sidebar"> <div id="reports-sidebar">
<ReportContent reports={reports} /> <ReportContent reports={reports} />
</div> </div>
+132 -100
View File
@@ -5,6 +5,7 @@ import { profileRows } from "../lib/profile";
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings"; import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller"; import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
import { Button, DialogFooter, DialogIntro, Field, FieldHint, Input, Select, Textarea } from "./ui";
interface Props { interface Props {
state: AppState; state: AppState;
@@ -27,12 +28,12 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
onClose(); onClose();
}} }}
> >
<div className="tab-panels"> <div className="flex flex-col">
<div className="tab-panel active"> <div className="flex flex-col gap-4">
{config.allowDirect && !config.lb && ( {config.allowDirect && !config.lb && (
<label className="field"> <Field>
<span>URL del servidor de voz</span> <span>URL del servidor de voz</span>
<input <Input
type="text" type="text"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
@@ -43,17 +44,17 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, directUrl: event.target.value }); setDraft({ ...draft, directUrl: event.target.value });
}} }}
/> />
<small> <FieldHint>
{config.s2sUrl {config.s2sUrl
? "El servidor fija esta dirección para todas las conversaciones." ? "El servidor fija esta dirección para todas las conversaciones."
: "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."} : "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."}
</small> </FieldHint>
</label> </Field>
)} )}
<div className="field-row"> <div className="grid grid-cols-2 gap-3">
<label className="field"> <Field>
<span>Voz</span> <span>Voz</span>
<select <Select
value={draft.voice} value={draft.voice}
onChange={(event) => { onChange={(event) => {
setDraft({ ...draft, voice: event.target.value }); setDraft({ ...draft, voice: event.target.value });
@@ -62,13 +63,13 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
{VOICES.map((voice) => ( {VOICES.map((voice) => (
<option key={voice}>{voice}</option> <option key={voice}>{voice}</option>
))} ))}
</select> </Select>
</label> </Field>
</div> </div>
<div className="field"> <div className="flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px]">
<label className="field-head" htmlFor="noise-gate"> <label className="flex items-baseline justify-between" htmlFor="noise-gate">
Puerta de ruido Puerta de ruido
<span className="field-value"> <span className="text-[12px] font-medium text-muted">
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`} {settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
</span> </span>
</label> </label>
@@ -93,14 +94,14 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
<span>−3 dB</span> <span>−3 dB</span>
</div> </div>
</div> </div>
<small> <FieldHint>
Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda Se aplica y guarda al instante. Silencia el micrófono por debajo del umbral. Desliza hasta la izquierda
para desactivarla. para desactivarla.
</small> </FieldHint>
</div> </div>
<label className="field"> <Field>
<span>Instrucciones</span> <span>Instrucciones</span>
<textarea <Textarea
rows={5} rows={5}
value={config.primaryPrompt || draft.instructions} value={config.primaryPrompt || draft.instructions}
readOnly={!!config.primaryPrompt} readOnly={!!config.primaryPrompt}
@@ -108,12 +109,13 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, instructions: event.target.value }); setDraft({ ...draft, instructions: event.target.value });
}} }}
/> />
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>} {config.primaryPrompt && <FieldHint>Las instrucciones están definidas por el servidor.</FieldHint>}
</label> </Field>
<div className="field"> <div className="flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px]">
<button <Button
variant="primary"
type="button" type="button"
className="btn primary wide" className="w-full"
disabled={!LIVE_STATES.has(state.status)} disabled={!LIVE_STATES.has(state.status)}
onClick={() => { onClick={() => {
controller.changeSettings(draft); controller.changeSettings(draft);
@@ -122,22 +124,22 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
}} }}
> >
Reiniciar conversación con esta configuración Reiniciar conversación con esta configuración
</button> </Button>
<small> <FieldHint>
Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren Al guardar, la voz y las instrucciones se aplican a la sesión actual. Los cambios de servidor requieren
una nueva conversación. una nueva conversación.
</small> </FieldHint>
</div> </div>
</div> </div>
</div> </div>
<footer className="modal-footer"> <DialogFooter>
<button className="btn secondary" type="button" onClick={onClose}> <Button type="button" onClick={onClose}>
Cancelar Cancelar
</button> </Button>
<button className="btn primary" type="submit"> <Button variant="primary" type="submit">
Guardar Guardar
</button> </Button>
</footer> </DialogFooter>
</form> </form>
</Dialog> </Dialog>
); );
@@ -157,10 +159,10 @@ export function RagDialog({ state, controller, onClose }: Props) {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
return ( return (
<Dialog title="Contexto RAG personalizado" onClose={onClose}> <Dialog title="Contexto RAG personalizado" onClose={onClose}>
<p className="tools-intro"> <DialogIntro>
Añade documentos, datos o instrucciones que Vocero debe tener en cuenta durante esta conversación. El contexto Añade documentos, datos o instrucciones que Vocero debe tener en cuenta durante esta conversación. El contexto
se mantiene mientras esta página esté abierta. se mantiene mientras esta página esté abierta.
</p> </DialogIntro>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
@@ -168,9 +170,9 @@ export function RagDialog({ state, controller, onClose }: Props) {
setSaved(true); setSaved(true);
}} }}
> >
<label className="field"> <Field>
<span>Contexto RAG (texto libre)</span> <span>Contexto RAG (texto libre)</span>
<textarea <Textarea
rows={12} rows={12}
spellCheck={false} spellCheck={false}
value={text} value={text}
@@ -180,17 +182,16 @@ export function RagDialog({ state, controller, onClose }: Props) {
}} }}
placeholder="Pega o escribe el contexto que Vocero debe usar al responder…" placeholder="Pega o escribe el contexto que Vocero debe usar al responder…"
/> />
</label> </Field>
<p className="tool-hint" role="status"> <p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
{saved {saved
? "Contexto actualizado para esta conversación." ? "Contexto actualizado para esta conversación."
: state.rag : state.rag
? `${String(state.rag.length)} caracteres de contexto cargados.` ? `${String(state.rag.length)} caracteres de contexto cargados.`
: "No hay contexto RAG cargado."} : "No hay contexto RAG cargado."}
</p> </p>
<footer className="modal-footer"> <DialogFooter>
<button <Button
className="btn secondary"
type="button" type="button"
onClick={() => { onClick={() => {
setText(""); setText("");
@@ -199,12 +200,12 @@ export function RagDialog({ state, controller, onClose }: Props) {
}} }}
> >
Vaciar Vaciar
</button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<button className="btn primary" type="submit"> <Button variant="primary" type="submit">
Enviar a la sesión Enviar a la sesión
</button> </Button>
</footer> </DialogFooter>
</form> </form>
</Dialog> </Dialog>
); );
@@ -217,9 +218,9 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
})); }));
return ( return (
<Dialog title="Herramientas" onClose={onClose}> <Dialog title="Herramientas" onClose={onClose}>
<p className="tools-intro"> <DialogIntro>
Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar. Permite que el asistente actúe durante la conversación. Los cambios se aplican al guardar.
</p> </DialogIntro>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
@@ -227,14 +228,17 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
onClose(); onClose();
}} }}
> >
<div className="tool-list"> <div className="flex flex-col">
<div className="tool-row"> <div className="flex items-center justify-between gap-4 py-3">
<div className="tool-info"> <div className="flex min-w-0 flex-col gap-[3px]">
<span className="tool-name">Búsqueda web</span> <span className="text-[15px] font-semibold text-foreground compact:text-[14px]">Búsqueda web</span>
<span className="tool-desc">Busca información en Google mediante Serper.</span> <span className="text-[13.5px] text-muted compact:text-[12.5px]">
Busca información en Google mediante Serper.
</span>
</div> </div>
<label className="switch"> <label className="relative inline-flex h-6 w-10 shrink-0 cursor-pointer">
<input <input
className="peer absolute inset-0 z-10 m-0 size-full cursor-pointer opacity-0"
type="checkbox" type="checkbox"
aria-label="Búsqueda web" aria-label="Búsqueda web"
checked={draft.webSearch} checked={draft.webSearch}
@@ -242,12 +246,15 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, webSearch: event.target.checked }); setDraft({ ...draft, webSearch: event.target.checked });
}} }}
/> />
<span className="switch-track" aria-hidden="true" /> <span
className="pointer-events-none absolute inset-0 rounded-full border border-solid border-line-strong bg-surface transition-colors peer-checked:border-foreground peer-checked:bg-foreground peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent after:absolute after:top-1/2 after:left-[3px] after:size-4 after:-translate-y-1/2 after:rounded-full after:bg-muted after:transition-transform after:content-[''] peer-checked:after:translate-x-4 peer-checked:after:bg-surface white:bg-slate-300 white:peer-checked:bg-accent"
aria-hidden="true"
/>
</label> </label>
</div> </div>
<label className="field tools-key"> <Field className="mb-1">
<span>Clave de la API de búsqueda</span> <span>Clave de la API de búsqueda</span>
<input <Input
type="password" type="password"
autoComplete="off" autoComplete="off"
value={draft.searchKey} value={draft.searchKey}
@@ -256,19 +263,23 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
setDraft({ ...draft, searchKey: event.target.value }); setDraft({ ...draft, searchKey: event.target.value });
}} }}
/> />
<small> <FieldHint>
{state.config.search {state.config.search
? "La búsqueda está configurada en el servidor." ? "La búsqueda está configurada en el servidor."
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."} : "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
</small> </FieldHint>
</label> </Field>
<div className="tool-row tool-row-sep"> <div className="mt-1 flex items-center justify-between gap-4 border-0 border-t border-solid border-line py-3">
<div className="tool-info"> <div className="flex min-w-0 flex-col gap-[3px]">
<span className="tool-name">Herramientas MCP cargadas</span> <span className="text-[15px] font-semibold text-foreground compact:text-[14px]">
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span> Herramientas MCP cargadas
</span>
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
Herramientas publicadas por el perfil remoto del agente.
</span>
</div> </div>
</div> </div>
<p className="tool-hint" role="status"> <p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
{state.toolsLoading {state.toolsLoading
? "Cargando herramientas…" ? "Cargando herramientas…"
: state.toolsError || : state.toolsError ||
@@ -276,23 +287,23 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
? `${String(state.tools.length)} herramientas disponibles.` ? `${String(state.tools.length)} herramientas disponibles.`
: "No hay herramientas remotas disponibles.")} : "No hay herramientas remotas disponibles.")}
</p> </p>
<div className="agent-tools-list"> <div className="flex flex-col gap-2">
{state.tools.map((tool) => ( {state.tools.map((tool) => (
<article key={tool.name} className="agent-tool-item"> <article key={tool.name} className="border-0 border-b border-solid border-line py-3 wrap-anywhere">
<strong className="block text-sm">{tool.name}</strong> <strong className="block text-sm">{tool.name}</strong>
<p className="mt-1 text-sm opacity-75">{tool.description}</p> <p className="mt-1 text-sm opacity-75">{tool.description}</p>
</article> </article>
))} ))}
</div> </div>
</div> </div>
<footer className="modal-footer"> <DialogFooter>
<button className="btn secondary" type="button" onClick={onClose}> <Button type="button" onClick={onClose}>
Cancelar Cancelar
</button> </Button>
<button className="btn primary" type="submit"> <Button variant="primary" type="submit">
Guardar Guardar
</button> </Button>
</footer> </DialogFooter>
</form> </form>
</Dialog> </Dialog>
); );
@@ -302,18 +313,23 @@ export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
const name = profileName(state.profile) || "Usuario"; const name = profileName(state.profile) || "Usuario";
const rows = profileRows(state.profile); const rows = profileRows(state.profile);
return ( return (
<Dialog title="Datos del usuario" className="user-profile-modal" onClose={onClose}> <Dialog title="Datos del usuario" size="profile" onClose={onClose}>
<div className="user-profile-hero"> <div className="flex flex-col items-center gap-3.5 pt-1.5 text-center">
<div className="user-profile-avatar" aria-hidden="true"> <div
className="relative inline-flex size-[88px] shrink-0 items-center justify-center rounded-full border border-solid border-brand/40 bg-linear-to-br from-accent to-brand-tertiary p-0 text-[34px] leading-none font-extrabold tracking-[0.04em] text-white uppercase shadow-brand select-none max-[480px]:size-[72px] max-[480px]:text-[28px]"
aria-hidden="true"
>
{initials(name)} {initials(name)}
</div> </div>
<h3 className="user-profile-title">{name}</h3> <h3 className="m-0 max-w-full text-[22px] leading-[1.2] font-extrabold tracking-[-0.01em] wrap-anywhere text-foreground max-[480px]:text-[19px]">
{name}
</h3>
</div> </div>
<dl className="user-profile-metadata"> <dl className="m-0 grid gap-y-3 border-0 border-t border-solid border-line px-1 pt-[18px] pb-1 empty:hidden max-[480px]:gap-y-1.5 max-[480px]:pt-4">
{rows.map(({ label, value, href }) => ( {rows.map(({ label, value, href }) => (
<div key={label} className="user-profile-row"> <div key={label} className="grid grid-cols-[90px_minmax(0,1fr)] gap-4 py-3">
<dt className="user-profile-label">{label}</dt> <dt className="text-muted">{label}</dt>
<dd className="user-profile-value">{href ? <a href={href}>{value}</a> : value}</dd> <dd className="m-0 min-w-0 wrap-anywhere">{href ? <a href={href}>{value}</a> : value}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -323,19 +339,25 @@ export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
export function AboutDialog({ onClose }: { onClose: () => void }) { export function AboutDialog({ onClose }: { onClose: () => void }) {
return ( return (
<Dialog title="Acerca de" className="about-modal" onClose={onClose}> <Dialog title="Acerca de" size="about" onClose={onClose}>
<p className="tools-intro"> <DialogIntro>
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
conversación. conversación.
</p> </DialogIntro>
<div className="about-pipeline"> <div className="border-0 border-t border-solid border-line pt-4">
<ol className="pipeline"> <ol className="relative m-0 list-none p-0 before:absolute before:inset-y-[7px] before:left-2 before:w-px before:bg-line-strong before:content-['']">
<li className="pipe-endpoint">Tú hablas</li> <li className="relative pb-3 pl-[30px] font-mono text-[12px] font-medium tracking-[0.12em] text-faint uppercase before:absolute before:top-[3px] before:left-[5px] before:size-[7px] before:rounded-full before:border before:border-solid before:border-faint before:bg-surface-raised before:content-[''] last:pb-0 compact:text-[11px]">
<li className="pipe-stage"> Tú hablas
<span className="pipe-tag">STT</span> </li>
<span className="pipe-job">transcribe tu voz</span> <li className="relative pb-4 pl-[30px] before:absolute before:top-1 before:left-[5px] before:size-[7px] before:rounded-full before:bg-muted before:content-['']">
<span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
STT
</span>
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
transcribe tu voz
</span>
<a <a
className="pipe-model" className="mt-1 inline-flex items-center gap-1 border-0 font-mono text-[13px] break-all text-foreground hover:underline hover:underline-offset-2 compact:text-[12px]"
href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3" href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -343,11 +365,15 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
nvidia/parakeet-tdt-0.6b-v3 nvidia/parakeet-tdt-0.6b-v3
</a> </a>
</li> </li>
<li className="pipe-stage"> <li className="relative pb-4 pl-[30px] before:absolute before:top-1 before:left-[5px] before:size-[7px] before:rounded-full before:bg-muted before:content-['']">
<span className="pipe-tag">LLM</span> <span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
<span className="pipe-job">genera una respuesta</span> LLM
</span>
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
genera una respuesta
</span>
<a <a
className="pipe-model" className="mt-1 inline-flex items-center gap-1 border-0 font-mono text-[13px] break-all text-foreground hover:underline hover:underline-offset-2 compact:text-[12px]"
href="https://huggingface.co/google/gemma-4-31B-it" href="https://huggingface.co/google/gemma-4-31B-it"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -355,11 +381,15 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
google/gemma-4-31B-it google/gemma-4-31B-it
</a> </a>
</li> </li>
<li className="pipe-stage"> <li className="relative pb-4 pl-[30px] before:absolute before:top-1 before:left-[5px] before:size-[7px] before:rounded-full before:bg-muted before:content-['']">
<span className="pipe-tag">TTS</span> <span className="mr-[9px] font-mono text-[13px] font-semibold tracking-[0.08em] text-foreground compact:text-[12px]">
<span className="pipe-job">la convierte en voz</span> TTS
</span>
<span className="text-[15px] text-muted after:mx-[7px] after:text-faint after:content-['·'] compact:text-[14px]">
la convierte en voz
</span>
<a <a
className="pipe-model" className="mt-1 inline-flex items-center gap-1 border-0 font-mono text-[13px] break-all text-foreground hover:underline hover:underline-offset-2 compact:text-[12px]"
href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -367,7 +397,9 @@ export function AboutDialog({ onClose }: { onClose: () => void }) {
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
</a> </a>
</li> </li>
<li className="pipe-endpoint">Vocero responde</li> <li className="relative pb-3 pl-[30px] font-mono text-[12px] font-medium tracking-[0.12em] text-faint uppercase before:absolute before:top-[3px] before:left-[5px] before:size-[7px] before:rounded-full before:border before:border-solid before:border-faint before:bg-surface-raised before:content-[''] last:pb-0 compact:text-[11px]">
Vocero responde
</li>
</ol> </ol>
</div> </div>
</Dialog> </Dialog>
+81
View File
@@ -0,0 +1,81 @@
import type { ComponentProps } from "react";
export function Button({
variant = "secondary",
className = "",
type = "button",
...props
}: ComponentProps<"button"> & { variant?: "primary" | "secondary" }) {
return (
<button
{...props}
type={type}
className={`rounded-control border border-solid px-4 py-2.5 text-[14px] font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent enabled:cursor-pointer enabled:active:translate-y-px disabled:cursor-not-allowed disabled:opacity-45 compact:text-[13px] ${
variant === "primary"
? "border-foreground bg-foreground text-surface enabled:hover:bg-white white:border-transparent white:bg-linear-to-br white:from-accent white:to-brand-secondary white:text-white white:disabled:opacity-60"
: "border-line-strong bg-surface-overlay text-foreground enabled:hover:border-foreground white:border-slate-900/10 white:enabled:hover:bg-slate-200"
} ${className}`}
/>
);
}
export function IconButton({
className = "",
type = "button",
round = false,
...props
}: ComponentProps<"button"> & { round?: boolean }) {
return (
<button
{...props}
type={type}
className={`relative inline-flex size-10 shrink-0 touch-manipulation items-center justify-center border border-solid border-line bg-surface-raised p-0 text-muted transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent enabled:cursor-pointer enabled:hover:border-line-strong enabled:hover:bg-surface-overlay enabled:hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45 ${round ? "rounded-full" : "rounded-control"} ${className}`}
/>
);
}
export function Field({ className = "", ...props }: ComponentProps<"label">) {
return (
<label
{...props}
className={`flex min-w-0 flex-col gap-1.5 text-[14px] font-medium text-muted compact:text-[13px] [&>span]:text-[13px] [&>span]:font-semibold [&>span]:tracking-[0.04em] [&>span]:text-foreground [&>span]:uppercase compact:[&>span]:text-[12px] ${className}`}
/>
);
}
const inputStyles =
"min-w-0 rounded-control border border-solid border-line bg-surface px-3 py-2.5 font-sans text-[14px] text-foreground outline-none transition-colors focus:border-muted disabled:opacity-60 white:border-slate-900/10 white:bg-surface-raised white:focus:border-brand-secondary white:focus:shadow-[0_0_0_3px_rgb(59_130_246/0.15)]";
export function Input({ className = "", ...props }: ComponentProps<"input">) {
return (
<input
{...props}
className={`${inputStyles} read-only:cursor-default read-only:border-dashed read-only:text-muted ${className}`}
/>
);
}
export function Select({ className = "", ...props }: ComponentProps<"select">) {
return <select {...props} className={`${inputStyles} ${className}`} />;
}
export function Textarea({ className = "", ...props }: ComponentProps<"textarea">) {
return (
<textarea
{...props}
className={`${inputStyles} min-h-[84px] resize-y text-[15px] compact:text-[14px] ${className}`}
/>
);
}
export function FieldHint({ className = "", ...props }: ComponentProps<"small">) {
return <small {...props} className={`text-[13px] leading-[1.4] text-faint compact:text-[12px] ${className}`} />;
}
export function DialogFooter({ className = "", ...props }: ComponentProps<"footer">) {
return <footer {...props} className={`flex justify-between gap-3 pt-1.5 ${className}`} />;
}
export function DialogIntro(props: ComponentProps<"p">) {
return <p {...props} className="m-0 text-[14px] leading-[1.5] text-muted compact:text-[13px]" />;
}