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
+40 -17
View File
@@ -10,37 +10,60 @@ export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [confirmOut, setConfirmOut] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const doLogout = () => {
logout();
navigate("/login");
};
const closeMobileMenu = () => setMobileOpen(false);
return (
<header className="navbar">
<div className="navbar-inner">
<Link to="/" className="brand">
<Link to="/" className="brand" onClick={closeMobileMenu}>
<span className="brand-logo">
<Icon name="document" size={18} />
</span>
GenExámenes IA
<span className="brand-text">GenExámenes IA</span>
</Link>
<nav className="nav-links">
<NavLink to="/" end className="nav-link">
Mis exámenes
</NavLink>
<NavLink to="/plantillas/nueva" className="nav-link">
Crear examen
</NavLink>
</nav>
<span className="nav-spacer" />
<div className="nav-user">
<div className="avatar" title={user?.email}>
{initials(user?.full_name || user?.email)}
<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">
<NavLink to="/" end className="nav-link" onClick={closeMobileMenu}>
Mis exámenes
</NavLink>
<NavLink to="/plantillas/nueva" className="nav-link" onClick={closeMobileMenu}>
Crear examen
</NavLink>
</nav>
<span className="nav-spacer" />
<div className="nav-user">
<div className="avatar" title={user?.email}>
{initials(user?.full_name || user?.email)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
closeMobileMenu();
setConfirmOut(true);
}}
>
Salir
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => setConfirmOut(true)}>
Salir
</Button>
</div>
</div>
+342 -26
View File
@@ -31,6 +31,10 @@
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--maxw: 1180px;
--h1: 30px;
--h2: 24px;
--h3: 19px;
--h4: 16px;
}
* {
@@ -48,7 +52,7 @@ body {
background: var(--c-bg);
color: var(--c-text);
font-size: 15px;
line-height: 1.55;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
@@ -64,10 +68,22 @@ h1,
h2,
h3,
h4 {
margin: 0 0 0.4em;
margin: 0 0 0.45em;
line-height: 1.25;
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 {
@@ -82,6 +98,12 @@ button {
.icon-inline {
margin-right: 6px;
}
.card-head .icon,
.tab .icon,
.btn .icon,
.badge .icon {
stroke-width: 1.9;
}
.icon-lg {
width: 40px;
height: 40px;
@@ -181,7 +203,7 @@ button {
.nav-link {
padding: 8px 14px;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
color: #4e566c;
font-weight: 500;
font-size: 14px;
}
@@ -228,16 +250,16 @@ button {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
margin-bottom: 26px;
flex-wrap: wrap;
}
.page-header h1 {
font-size: 26px;
font-size: clamp(24px, 3vw, 30px);
margin: 0;
}
.page-header p {
margin: 4px 0 0;
color: var(--c-text-soft);
margin: 7px 0 0;
color: #5a6277;
}
.page-header-actions {
margin-left: auto;
@@ -245,6 +267,23 @@ button {
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 ---------- */
.card {
background: var(--c-surface);
@@ -253,10 +292,10 @@ button {
box-shadow: var(--shadow-sm);
}
.card-pad {
padding: 22px;
padding: 20px;
}
.card-head {
padding: 18px 22px;
padding: 16px 20px;
border-bottom: 1px solid var(--c-border);
display: flex;
align-items: center;
@@ -265,9 +304,10 @@ button {
.card-head h3 {
margin: 0;
font-size: 16px;
letter-spacing: -0.01em;
}
.card-body {
padding: 22px;
padding: 20px;
}
.grid {
@@ -365,12 +405,12 @@ button {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--c-text);
color: #2a3143;
margin-bottom: 7px;
}
.field-hint {
font-size: 12.5px;
color: var(--c-text-faint);
color: #78809a;
margin-top: 6px;
}
.field-error {
@@ -440,12 +480,12 @@ button {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--c-surface-2);
color: var(--c-text-soft);
color: #505a74;
}
.badge-primary {
background: var(--c-primary-soft);
@@ -477,10 +517,10 @@ button {
overflow-x: auto;
}
.tab {
padding: 11px 16px;
padding: 11px 15px;
border: none;
background: none;
color: var(--c-text-soft);
color: #556079;
font-weight: 600;
font-size: 14px;
cursor: pointer;
@@ -492,7 +532,7 @@ button {
gap: 7px;
}
.tab:hover {
color: var(--c-text);
color: #252c3f;
}
.tab.active {
color: var(--c-primary);
@@ -627,7 +667,7 @@ button {
display: flex;
gap: 16px;
flex-wrap: wrap;
color: var(--c-text-soft);
color: #586178;
font-size: 13px;
}
.meta-row span {
@@ -668,13 +708,17 @@ button {
}
.list-item-sub {
font-size: 13px;
color: var(--c-text-faint);
color: #7a839c;
}
.empty-state {
text-align: center;
padding: 56px 24px;
color: var(--c-text-soft);
color: #5c657a;
}
.empty-state p {
margin: 6px auto 0;
max-width: 56ch;
}
.empty-state-icon {
margin: 0 auto 12px;
@@ -813,15 +857,15 @@ button {
}
.toast-content {
flex: 1;
font-size: 14px;
font-size: 13.5px;
}
.toast-title {
font-weight: 600;
margin-bottom: 2px;
}
.toast-msg {
color: var(--c-text-soft);
font-size: 13px;
color: #5b6378;
font-size: 12.5px;
}
.toast-close {
background: none;
@@ -863,14 +907,17 @@ button {
.mt-lg {
margin-top: 26px;
}
.mb-sm {
margin-bottom: 10px;
}
.mb {
margin-bottom: 16px;
}
.text-soft {
color: var(--c-text-soft);
color: #5a6379;
}
.text-faint {
color: var(--c-text-faint);
color: #7b849c;
}
.text-sm {
font-size: 13px;
@@ -933,3 +980,272 @@ button {
border: 1px solid var(--c-border);
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>
<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>
@@ -192,7 +192,7 @@ export default function CreateTemplatePage() {
className="card"
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>
{types.length > 1 && (
<Button
@@ -263,7 +263,7 @@ export default function CreateTemplatePage() {
}
/>
</Field>
<div style={{ paddingTop: 26 }}>
<div style={{ paddingTop: 8 }}>
<Checkbox
label="Permitir varias respuestas correctas"
checked={t.multiple_correct}
@@ -343,7 +343,7 @@ export default function CreateTemplatePage() {
</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("/")}>
Cancelar
</Button>
+2 -2
View File
@@ -32,10 +32,10 @@ export default function DashboardPage() {
<div className="page-header">
<div>
<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 className="page-header-actions">
<Button onClick={() => navigate("/plantillas/nueva")}>
<Button onClick={() => navigate("/plantillas/nueva")} size="lg">
<Icon name="plus" size={16} className="icon-inline" />
Nuevo examen
</Button>
+18
View File
@@ -154,6 +154,24 @@ export default function TemplateDetailPage() {
</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">
{TABS.map((t) => (
<button
+2 -2
View File
@@ -73,7 +73,7 @@ export default function ExportTab({ templateId, template, questions }) {
return (
<div>
<p className="text-soft mb">
<p className="text-soft page-lead mb">
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
formato para descargarlo o previsualizarlo.
</p>
@@ -87,7 +87,7 @@ export default function ExportTab({ templateId, template, questions }) {
<p className="text-sm text-soft" style={{ minHeight: 60 }}>
{fmt.desc}
</p>
<div className="flex gap-sm">
<div className="flex gap-sm mobile-stack">
<Button
onClick={() => run(fmt, { download: true })}
loading={loadingFormat === fmt.id}
+6 -3
View File
@@ -104,7 +104,7 @@ export default function GenerateTab({
const topicTooShort = topic.trim().length < 5;
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 340px" }}>
<div className="grid layout-split">
<div>
<div className="tabs" style={{ marginBottom: 18 }}>
{MODES.map((m) => (
@@ -122,6 +122,9 @@ export default function GenerateTab({
{mode !== "parse" && (
<div className="card mb">
<div className="card-body">
<h3 className="section-title mb-sm">
{mode === "auto" ? "Generación automática" : "Construcción de prompt"}
</h3>
<Field
label="Tema / instrucciones para la IA"
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
@@ -157,7 +160,7 @@ export default function GenerateTab({
</Field>
)}
<div className="flex gap mt">
<div className="flex gap mt mobile-stack">
{mode === "auto" ? (
<Button
size="lg"
@@ -244,7 +247,7 @@ export default function GenerateTab({
{generated.length > 0 && (
<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 }}>
Resultado ({generated.length} preguntas)
</h3>
+3 -1
View File
@@ -65,10 +65,12 @@ export default function ImagesTab({
};
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
<div className="grid layout-split">
<div>
<div className="card mb">
<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
accept={IMAGE_ACCEPT}
icon="image"
+4 -2
View File
@@ -67,10 +67,12 @@ export default function MaterialsTab({
};
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
<div className="grid layout-split">
<div>
<div className="card mb">
<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 ? (
<div className="text-center" style={{ padding: 24 }}>
<Spinner large />
@@ -127,7 +129,7 @@ export default function MaterialsTab({
</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>
<Button
variant="danger-ghost"
+5 -4
View File
@@ -13,18 +13,18 @@ export default function OverviewTab({ template, storage, goToTab }) {
const profile = template.difficulty_profile || {};
return (
<div className="grid" style={{ gridTemplateColumns: "1.4fr 1fr" }}>
<div className="grid layout-split-wide">
<div>
<div className="card mb">
<div className="card-head">
<h3>Estructura del examen</h3>
</div>
<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) => (
<div
key={i}
className="flex justify-between items-center"
className="flex justify-between items-center wrap gap-sm"
style={{
padding: "10px 0",
borderBottom:
@@ -50,7 +50,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
))}
<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">
{Object.entries(profile).map(([key, val]) =>
val > 0 ? (
@@ -62,6 +62,7 @@ export default function OverviewTab({ template, storage, goToTab }) {
</div>
<div className="divider-line" />
<h4 className="section-title">Opciones activas</h4>
<div className="flex gap-sm wrap text-sm">
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
<Icon
+2 -2
View File
@@ -52,8 +52,8 @@ export default function QuestionsTab({
return (
<div>
<div className="flex justify-between items-center mb">
<p className="text-soft" style={{ margin: 0 }}>
<div className="flex justify-between items-center mb wrap gap-sm">
<p className="text-soft page-lead" style={{ margin: 0 }}>
{questions.length} preguntas guardadas. Vincula imágenes a las
preguntas que las necesiten.
</p>