Improve responsive UX/UI consistency across all frontend screens.

This polish pass unifies mobile navigation, spacing, typography hierarchy, and CTA behavior so all core exam workflows remain clear and fully usable on both mobile and desktop.
This commit is contained in:
Mireya Cueto Garrido
2026-06-02 12:56:30 +02:00
parent eec534922a
commit d7f9ae8841
11 changed files with 428 additions and 63 deletions
+28 -5
View File
@@ -10,26 +10,41 @@ export default function Navbar() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [confirmOut, setConfirmOut] = useState(false); const [confirmOut, setConfirmOut] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const doLogout = () => { const doLogout = () => {
logout(); logout();
navigate("/login"); navigate("/login");
}; };
const closeMobileMenu = () => setMobileOpen(false);
return ( return (
<header className="navbar"> <header className="navbar">
<div className="navbar-inner"> <div className="navbar-inner">
<Link to="/" className="brand"> <Link to="/" className="brand" onClick={closeMobileMenu}>
<span className="brand-logo"> <span className="brand-logo">
<Icon name="document" size={18} /> <Icon name="document" size={18} />
</span> </span>
GenExámenes IA <span className="brand-text">GenExámenes IA</span>
</Link> </Link>
<Button
variant="ghost"
size="sm"
className="nav-mobile-toggle"
aria-label={mobileOpen ? "Cerrar menú" : "Abrir menú"}
onClick={() => setMobileOpen((open) => !open)}
>
<Icon name={mobileOpen ? "close" : "listChecks"} size={18} />
</Button>
<div className={`nav-collapse ${mobileOpen ? "open" : ""}`}>
<nav className="nav-links"> <nav className="nav-links">
<NavLink to="/" end className="nav-link"> <NavLink to="/" end className="nav-link" onClick={closeMobileMenu}>
Mis exámenes Mis exámenes
</NavLink> </NavLink>
<NavLink to="/plantillas/nueva" className="nav-link"> <NavLink to="/plantillas/nueva" className="nav-link" onClick={closeMobileMenu}>
Crear examen Crear examen
</NavLink> </NavLink>
</nav> </nav>
@@ -38,11 +53,19 @@ export default function Navbar() {
<div className="avatar" title={user?.email}> <div className="avatar" title={user?.email}>
{initials(user?.full_name || user?.email)} {initials(user?.full_name || user?.email)}
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setConfirmOut(true)}> <Button
variant="ghost"
size="sm"
onClick={() => {
closeMobileMenu();
setConfirmOut(true);
}}
>
Salir Salir
</Button> </Button>
</div> </div>
</div> </div>
</div>
<Modal <Modal
open={confirmOut} open={confirmOut}
+342 -26
View File
@@ -31,6 +31,10 @@
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; --font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--maxw: 1180px; --maxw: 1180px;
--h1: 30px;
--h2: 24px;
--h3: 19px;
--h4: 16px;
} }
* { * {
@@ -48,7 +52,7 @@ body {
background: var(--c-bg); background: var(--c-bg);
color: var(--c-text); color: var(--c-text);
font-size: 15px; font-size: 15px;
line-height: 1.55; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -64,10 +68,22 @@ h1,
h2, h2,
h3, h3,
h4 { h4 {
margin: 0 0 0.4em; margin: 0 0 0.45em;
line-height: 1.25; line-height: 1.25;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.012em;
}
h1 {
font-size: var(--h1);
}
h2 {
font-size: var(--h2);
}
h3 {
font-size: var(--h3);
}
h4 {
font-size: var(--h4);
} }
button { button {
@@ -82,6 +98,12 @@ button {
.icon-inline { .icon-inline {
margin-right: 6px; margin-right: 6px;
} }
.card-head .icon,
.tab .icon,
.btn .icon,
.badge .icon {
stroke-width: 1.9;
}
.icon-lg { .icon-lg {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -181,7 +203,7 @@ button {
.nav-link { .nav-link {
padding: 8px 14px; padding: 8px 14px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--c-text-soft); color: #4e566c;
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
} }
@@ -228,16 +250,16 @@ button {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 26px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.page-header h1 { .page-header h1 {
font-size: 26px; font-size: clamp(24px, 3vw, 30px);
margin: 0; margin: 0;
} }
.page-header p { .page-header p {
margin: 4px 0 0; margin: 7px 0 0;
color: var(--c-text-soft); color: #5a6277;
} }
.page-header-actions { .page-header-actions {
margin-left: auto; margin-left: auto;
@@ -245,6 +267,23 @@ button {
gap: 10px; gap: 10px;
} }
.page-lead {
max-width: 68ch;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--c-text);
margin: 0 0 8px;
}
.section-subtle {
font-size: 13px;
color: var(--c-text-faint);
margin: 0;
}
/* ---------- Cards ---------- */ /* ---------- Cards ---------- */
.card { .card {
background: var(--c-surface); background: var(--c-surface);
@@ -253,10 +292,10 @@ button {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.card-pad { .card-pad {
padding: 22px; padding: 20px;
} }
.card-head { .card-head {
padding: 18px 22px; padding: 16px 20px;
border-bottom: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -265,9 +304,10 @@ button {
.card-head h3 { .card-head h3 {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
letter-spacing: -0.01em;
} }
.card-body { .card-body {
padding: 22px; padding: 20px;
} }
.grid { .grid {
@@ -365,12 +405,12 @@ button {
display: block; display: block;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--c-text); color: #2a3143;
margin-bottom: 7px; margin-bottom: 7px;
} }
.field-hint { .field-hint {
font-size: 12.5px; font-size: 12.5px;
color: var(--c-text-faint); color: #78809a;
margin-top: 6px; margin-top: 6px;
} }
.field-error { .field-error {
@@ -440,12 +480,12 @@ button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding: 3px 10px; padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
background: var(--c-surface-2); background: var(--c-surface-2);
color: var(--c-text-soft); color: #505a74;
} }
.badge-primary { .badge-primary {
background: var(--c-primary-soft); background: var(--c-primary-soft);
@@ -477,10 +517,10 @@ button {
overflow-x: auto; overflow-x: auto;
} }
.tab { .tab {
padding: 11px 16px; padding: 11px 15px;
border: none; border: none;
background: none; background: none;
color: var(--c-text-soft); color: #556079;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
@@ -492,7 +532,7 @@ button {
gap: 7px; gap: 7px;
} }
.tab:hover { .tab:hover {
color: var(--c-text); color: #252c3f;
} }
.tab.active { .tab.active {
color: var(--c-primary); color: var(--c-primary);
@@ -627,7 +667,7 @@ button {
display: flex; display: flex;
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
color: var(--c-text-soft); color: #586178;
font-size: 13px; font-size: 13px;
} }
.meta-row span { .meta-row span {
@@ -668,13 +708,17 @@ button {
} }
.list-item-sub { .list-item-sub {
font-size: 13px; font-size: 13px;
color: var(--c-text-faint); color: #7a839c;
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 56px 24px; padding: 56px 24px;
color: var(--c-text-soft); color: #5c657a;
}
.empty-state p {
margin: 6px auto 0;
max-width: 56ch;
} }
.empty-state-icon { .empty-state-icon {
margin: 0 auto 12px; margin: 0 auto 12px;
@@ -813,15 +857,15 @@ button {
} }
.toast-content { .toast-content {
flex: 1; flex: 1;
font-size: 14px; font-size: 13.5px;
} }
.toast-title { .toast-title {
font-weight: 600; font-weight: 600;
margin-bottom: 2px; margin-bottom: 2px;
} }
.toast-msg { .toast-msg {
color: var(--c-text-soft); color: #5b6378;
font-size: 13px; font-size: 12.5px;
} }
.toast-close { .toast-close {
background: none; background: none;
@@ -863,14 +907,17 @@ button {
.mt-lg { .mt-lg {
margin-top: 26px; margin-top: 26px;
} }
.mb-sm {
margin-bottom: 10px;
}
.mb { .mb {
margin-bottom: 16px; margin-bottom: 16px;
} }
.text-soft { .text-soft {
color: var(--c-text-soft); color: #5a6379;
} }
.text-faint { .text-faint {
color: var(--c-text-faint); color: #7b849c;
} }
.text-sm { .text-sm {
font-size: 13px; font-size: 13px;
@@ -933,3 +980,272 @@ button {
border: 1px solid var(--c-border); border: 1px solid var(--c-border);
background: var(--c-surface-2); background: var(--c-surface-2);
} }
.layout-split {
grid-template-columns: minmax(0, 1fr) 320px;
}
.layout-split-wide {
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 1fr);
}
.tabs-select-wrap {
display: none;
margin-bottom: 14px;
}
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.navbar-inner {
padding: 0 16px;
gap: 12px;
}
.page {
padding: 24px 16px 42px;
}
}
@media (max-width: 900px) {
.list-item {
flex-wrap: wrap;
align-items: flex-start;
}
.list-item-actions {
width: 100%;
justify-content: flex-end;
}
.layout-split,
.layout-split-wide {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
:root {
--radius: 10px;
--radius-lg: 14px;
--h1: 26px;
--h2: 22px;
--h3: 18px;
--h4: 15px;
}
.navbar-inner {
height: auto;
min-height: 64px;
flex-wrap: wrap;
align-items: center;
padding-top: 10px;
padding-bottom: 10px;
}
.brand {
min-width: 0;
flex: 1;
}
.brand-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-mobile-toggle {
display: inline-flex;
}
.nav-collapse {
width: 100%;
display: none;
flex-direction: column;
gap: 10px;
padding: 8px 0 2px;
border-top: 1px solid var(--c-border);
}
.nav-collapse.open {
display: flex;
}
.nav-links {
margin-left: 0;
width: 100%;
flex-direction: column;
gap: 6px;
}
.nav-link {
width: 100%;
min-height: 42px;
display: inline-flex;
align-items: center;
padding: 10px 12px;
}
.nav-spacer {
display: none;
}
.nav-user {
width: 100%;
justify-content: space-between;
border-top: 1px solid var(--c-border);
padding-top: 10px;
}
.page-header h1 {
font-size: 25px;
}
.page-header p,
.page-lead {
font-size: 14px;
}
.page-header-actions {
margin-left: 0;
width: 100%;
}
.page-header-actions .btn {
width: 100%;
}
.card-head,
.card-body,
.card-pad {
padding: 15px;
}
.card-head {
flex-wrap: wrap;
}
.card-head > .btn {
width: 100%;
}
.card-head h3 {
font-size: 15px;
}
.tabs {
gap: 6px;
margin-left: -4px;
margin-right: -4px;
padding: 0 4px;
scrollbar-width: thin;
}
.tabs-select-wrap {
display: block;
}
.tab {
min-height: 44px;
padding: 10px 13px;
}
.btn {
min-height: 42px;
}
.btn.btn-sm {
min-height: 38px;
}
.btn.btn-lg {
min-height: 46px;
}
.mobile-stack {
flex-direction: column;
align-items: stretch;
}
.mobile-stack > .btn {
width: 100%;
}
.modal-overlay {
padding: 12px;
align-items: flex-end;
}
.modal {
max-height: 94vh;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.modal-head,
.modal-body,
.modal-foot {
padding-left: 16px;
padding-right: 16px;
}
.modal-foot {
flex-wrap: wrap;
}
.modal-foot .btn {
flex: 1;
min-width: 130px;
}
.toast-stack {
top: auto;
bottom: 12px;
right: 12px;
left: 12px;
max-width: none;
}
.spinner-center {
padding: 36px 0;
}
.empty-state {
padding: 34px 16px;
}
.empty-state h3 {
font-size: 18px;
margin-bottom: 6px;
}
.empty-state .btn {
width: 100%;
}
.code-block {
max-height: 320px;
font-size: 12px;
padding: 12px;
}
.progress {
height: 9px;
}
.list-item-sub {
margin-top: 2px;
}
}
@media (min-width: 761px) {
.nav-mobile-toggle {
display: none;
}
.nav-collapse {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
}
}
+4 -4
View File
@@ -122,7 +122,7 @@ export default function CreateTemplatePage() {
<div className="page-header"> <div className="page-header">
<div> <div>
<h1>Nuevo examen</h1> <h1>Nuevo examen</h1>
<p>Define la estructura. Después podrás subir material y generar preguntas.</p> <p className="page-lead">Define la estructura. Después podrás subir material y generar preguntas.</p>
</div> </div>
</div> </div>
@@ -192,7 +192,7 @@ export default function CreateTemplatePage() {
className="card" className="card"
style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }} style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }}
> >
<div className="flex justify-between items-center mb"> <div className="flex justify-between items-center mb wrap gap-sm">
<strong>Bloque {idx + 1}</strong> <strong>Bloque {idx + 1}</strong>
{types.length > 1 && ( {types.length > 1 && (
<Button <Button
@@ -263,7 +263,7 @@ export default function CreateTemplatePage() {
} }
/> />
</Field> </Field>
<div style={{ paddingTop: 26 }}> <div style={{ paddingTop: 8 }}>
<Checkbox <Checkbox
label="Permitir varias respuestas correctas" label="Permitir varias respuestas correctas"
checked={t.multiple_correct} checked={t.multiple_correct}
@@ -343,7 +343,7 @@ export default function CreateTemplatePage() {
</div> </div>
</div> </div>
<div className="flex gap justify-between"> <div className="flex gap justify-between wrap mobile-stack mt">
<Button type="button" variant="ghost" onClick={() => navigate("/")}> <Button type="button" variant="ghost" onClick={() => navigate("/")}>
Cancelar Cancelar
</Button> </Button>
+2 -2
View File
@@ -32,10 +32,10 @@ export default function DashboardPage() {
<div className="page-header"> <div className="page-header">
<div> <div>
<h1>Mis exámenes</h1> <h1>Mis exámenes</h1>
<p>Gestiona tus plantillas de examen y genera preguntas con IA.</p> <p className="page-lead">Gestiona tus plantillas de examen y genera preguntas con IA.</p>
</div> </div>
<div className="page-header-actions"> <div className="page-header-actions">
<Button onClick={() => navigate("/plantillas/nueva")}> <Button onClick={() => navigate("/plantillas/nueva")} size="lg">
<Icon name="plus" size={16} className="icon-inline" /> <Icon name="plus" size={16} className="icon-inline" />
Nuevo examen Nuevo examen
</Button> </Button>
+18
View File
@@ -154,6 +154,24 @@ export default function TemplateDetailPage() {
</div> </div>
</div> </div>
<div className="tabs-select-wrap">
<label className="field-label" htmlFor="template-tab-select">
Sección actual
</label>
<select
id="template-tab-select"
className="select"
value={tab}
onChange={(e) => setTab(e.target.value)}
>
{TABS.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</div>
<div className="tabs"> <div className="tabs">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
+2 -2
View File
@@ -73,7 +73,7 @@ export default function ExportTab({ templateId, template, questions }) {
return ( return (
<div> <div>
<p className="text-soft mb"> <p className="text-soft page-lead mb">
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
formato para descargarlo o previsualizarlo. formato para descargarlo o previsualizarlo.
</p> </p>
@@ -87,7 +87,7 @@ export default function ExportTab({ templateId, template, questions }) {
<p className="text-sm text-soft" style={{ minHeight: 60 }}> <p className="text-sm text-soft" style={{ minHeight: 60 }}>
{fmt.desc} {fmt.desc}
</p> </p>
<div className="flex gap-sm"> <div className="flex gap-sm mobile-stack">
<Button <Button
onClick={() => run(fmt, { download: true })} onClick={() => run(fmt, { download: true })}
loading={loadingFormat === fmt.id} loading={loadingFormat === fmt.id}
+6 -3
View File
@@ -104,7 +104,7 @@ export default function GenerateTab({
const topicTooShort = topic.trim().length < 5; const topicTooShort = topic.trim().length < 5;
return ( return (
<div className="grid" style={{ gridTemplateColumns: "1fr 340px" }}> <div className="grid layout-split">
<div> <div>
<div className="tabs" style={{ marginBottom: 18 }}> <div className="tabs" style={{ marginBottom: 18 }}>
{MODES.map((m) => ( {MODES.map((m) => (
@@ -122,6 +122,9 @@ export default function GenerateTab({
{mode !== "parse" && ( {mode !== "parse" && (
<div className="card mb"> <div className="card mb">
<div className="card-body"> <div className="card-body">
<h3 className="section-title mb-sm">
{mode === "auto" ? "Generación automática" : "Construcción de prompt"}
</h3>
<Field <Field
label="Tema / instrucciones para la IA" label="Tema / instrucciones para la IA"
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)." hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
@@ -157,7 +160,7 @@ export default function GenerateTab({
</Field> </Field>
)} )}
<div className="flex gap mt"> <div className="flex gap mt mobile-stack">
{mode === "auto" ? ( {mode === "auto" ? (
<Button <Button
size="lg" size="lg"
@@ -244,7 +247,7 @@ export default function GenerateTab({
{generated.length > 0 && ( {generated.length > 0 && (
<div className="mt-lg"> <div className="mt-lg">
<div className="flex justify-between items-center mb"> <div className="flex justify-between items-center mb wrap gap-sm">
<h3 style={{ margin: 0 }}> <h3 style={{ margin: 0 }}>
Resultado ({generated.length} preguntas) Resultado ({generated.length} preguntas)
</h3> </h3>
+3 -1
View File
@@ -65,10 +65,12 @@ export default function ImagesTab({
}; };
return ( return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}> <div className="grid layout-split">
<div> <div>
<div className="card mb"> <div className="card mb">
<div className="card-body"> <div className="card-body">
<h3 className="section-title">Subir imágenes del examen</h3>
<p className="section-subtle mb">Añade recursos visuales para preguntas con soporte gráfico.</p>
<FileDropzone <FileDropzone
accept={IMAGE_ACCEPT} accept={IMAGE_ACCEPT}
icon="image" icon="image"
+4 -2
View File
@@ -67,10 +67,12 @@ export default function MaterialsTab({
}; };
return ( return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}> <div className="grid layout-split">
<div> <div>
<div className="card mb"> <div className="card mb">
<div className="card-body"> <div className="card-body">
<h3 className="section-title">Subir material para IA</h3>
<p className="section-subtle mb">Soporta PDF, DOCX, TXT, MD e imágenes con OCR.</p>
{uploading ? ( {uploading ? (
<div className="text-center" style={{ padding: 24 }}> <div className="text-center" style={{ padding: 24 }}>
<Spinner large /> <Spinner large />
@@ -127,7 +129,7 @@ export default function MaterialsTab({
</div> </div>
)} )}
</div> </div>
<div className="flex gap-sm items-center" style={{ flex: "none" }}> <div className="flex gap-sm items-center mobile-stack list-item-actions" style={{ flex: "none" }}>
<Badge variant={badge.variant}>{badge.label}</Badge> <Badge variant={badge.variant}>{badge.label}</Badge>
<Button <Button
variant="danger-ghost" variant="danger-ghost"
+5 -4
View File
@@ -13,18 +13,18 @@ export default function OverviewTab({ template, storage, goToTab }) {
const profile = template.difficulty_profile || {}; const profile = template.difficulty_profile || {};
return ( return (
<div className="grid" style={{ gridTemplateColumns: "1.4fr 1fr" }}> <div className="grid layout-split-wide">
<div> <div>
<div className="card mb"> <div className="card mb">
<div className="card-head"> <div className="card-head">
<h3>Estructura del examen</h3> <h3>Estructura del examen</h3>
</div> </div>
<div className="card-body"> <div className="card-body">
<h4 className="text-soft text-sm">Tipos de pregunta</h4> <h4 className="section-title">Tipos de pregunta</h4>
{qTypes.map((qt, i) => ( {qTypes.map((qt, i) => (
<div <div
key={i} key={i}
className="flex justify-between items-center" className="flex justify-between items-center wrap gap-sm"
style={{ style={{
padding: "10px 0", padding: "10px 0",
borderBottom: borderBottom:
@@ -50,7 +50,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
))} ))}
<div className="divider-line" /> <div className="divider-line" />
<h4 className="text-soft text-sm">Reparto por dificultad</h4> <h4 className="section-title">Reparto por dificultad</h4>
<div className="flex gap-sm wrap"> <div className="flex gap-sm wrap">
{Object.entries(profile).map(([key, val]) => {Object.entries(profile).map(([key, val]) =>
val > 0 ? ( val > 0 ? (
@@ -62,6 +62,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
</div> </div>
<div className="divider-line" /> <div className="divider-line" />
<h4 className="section-title">Opciones activas</h4>
<div className="flex gap-sm wrap text-sm"> <div className="flex gap-sm wrap text-sm">
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}> <Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
<Icon <Icon
+2 -2
View File
@@ -52,8 +52,8 @@ export default function QuestionsTab({
return ( return (
<div> <div>
<div className="flex justify-between items-center mb"> <div className="flex justify-between items-center mb wrap gap-sm">
<p className="text-soft" style={{ margin: 0 }}> <p className="text-soft page-lead" style={{ margin: 0 }}>
{questions.length} preguntas guardadas. Vincula imágenes a las {questions.length} preguntas guardadas. Vincula imágenes a las
preguntas que las necesiten. preguntas que las necesiten.
</p> </p>