Saltar al contenido principal

`/mi-cuenta/*` — layout, grid and IA spec (portal, student surface)

:::note Contenido en inglés Esta página del wiki se sincroniza desde la base de conocimiento en inglés y todavía no está traducida. :::

canonical · designer · updated 2026-09-06 · source

Design Spec for issue #1631, produced after José's live review of the ad-hoc polish rounds on /mi-cuenta/perfil. Reviews what shipped, corrects it, and extends the corrections to the two sibling pages the shared holder now constrains (/mi-cuenta/cursos, /mi-cuenta/certificados).

Written without a browser — every number below is derived from the JSX/Tailwind source plus the token layer, not from a rendered screenshot. Items that genuinely need eyes-on confirmation are marked [EYES].

Rubric: knowledge/design-principles.md (the 8 Apple WWDC26 principles). Layout law: .claude/rules/grid-system.md (G2 base-8, G1 grid-by-construction). Tokens + typography: .claude/rules/foundation-ui-conventions.md. Routing: .claude/rules/design-routing.md — this doc is the DECISION; Frontend implements. Related: tedos-portal-screen-map.md, grid-system.md, design-principles.md.


0. The measurements everything below rests on

PageContainer inset="bleed" in mi-cuenta/layout.tsx resolves to max-w-content (portal --width-content: 120rem = 1920px) with --inset-bleed: max(1.5rem, 8.3333%) side padding. So for viewport W:

inner = 0.8333 × min(W, 1920) (container minus its bleed)
track = inner − sidebar(224) − gap (the content column, at md+)

Current shipped code adds a second inset — md:grid-cols-10 + md:col-span-8 md:col-start-2, with no gutter — costing another 20% of the track. Measured content width today:

Viewportinnerafter sidebar+gap-8× 0.8 (the 8-of-10 inset)
12801067811649
14401200944755
153612801024819
1920160013441075

This single fact explains almost every symptom in the review.


1. Verdict on the perfil layout (Task 1)

#ShippedVerdictFix
1.1Content column = md:grid md:grid-cols-10 + md:col-span-8 md:col-start-2Wrong. It is percentage padding dressed as a grid: 10 equal columns with no gutter, so nothing inside it can align to a column line (G1 violated by construction). It double-insets a container that already carries inset="bleed", and it applies to all five subsections, starving the card grids (§2).Delete it. Content column = min-w-0 flex-1 pb-16. Per-page measure is the page's job.
1.2PageHeader wrapped in a nested grid gap-6 md:grid-cols-8 at md:col-span-4Wrong, and actively harmful. At 1440 that box is 366px. PageHeader already caps its subtitle at max-w-prose (~494px at type-body), so the cap does nothing except force the 56-char subtitle to wrap onto a second line it wouldn't otherwise need. It is also a third nested grid whose columns line up with neither of the other two.Delete the wrapper. PageHeader's own max-w-prose is the measure. Same treatment as cursos/certificados already have → one pattern across all five pages (§3.1).
1.3<Grid className="gap-12"> with every child <GridItem span={12}>Wrong. A 12-column grid where every item spans 12 is a vertical stack; the gap-12 override fights the primitive's own 24px gutter and the Grid/GridItem import buys nothing.flex flex-col gap-8.
1.448px (gap-12) between sections, each card also padding="md" (24px internal)Arbitrary. 48px is base-8-legal but unrelated to anything else on the page; it reads as four disconnected pages stacked.32px (gap-8) — identical to PageHeader's built-in mb-8 and to the layout's own sidebar gap, so header→card and card→card are the same interval. One rhythm number down the page (G1).
1.5Sidebar md:w-56 (224px)Right, keep. Longest label ("Mis certificados", type-small) + icon + px-3 ≈ 164px — 60px of slack, no wrap risk. 224/1152 ≈ 19% of the row: normal settings-nav proportion.
1.6Sidebar md:sticky md:top-24Right, keep. The header is sticky top-0 z-30 at 72px (h-18); 96 = 72 + 24, i.e. one 24px clearance under the pinned rail. top does not affect the unstuck position, so the flush initial alignment is preserved.
1.7Sidebar↔content gap-8 (32px)Was masked by the ~94px of phantom inset from 1.1. With that inset gone, 32px is tight for a nav/content seam.md:gap-12 (48px) at md+; keep gap-8 for the stacked (<md) direction, where the nav sits above and 32px is correct.
1.8Card title SectionLabel as="h3" className="type-h2 text-fg-primary"Hierarchy is right, the API use is not. 26px → 16px is a 1.63× step (≥1.25 required) and reads cleanly: page title type-section → card title type-h2 → field label. But SectionLabel is the type-label role; overriding both its type class and its color means it is no longer a SectionLabel. as="h3" also skips a heading level under the page h1.Plain <h2 className="type-h2 text-fg-primary"> inside AccountSectionCard. Matches how cursos/certificados already title their cards.
1.9Perfil content stretches the full track (1075px at 1920 today, 1328px after 1.1)Needs a measure. A stack of label/value cards at 1328px puts ~640px per dl column for a value like a first name.max-w-4xl (896px) on the perfil page root — header and stack together, so the right edge never goes ragged. At 1440 (track 928) it is effectively full-bleed; at 1920 it leaves a deliberate right margin — the standard settings-page shape.
1.10Helper paragraphs in PhoneChange run the full card widthAt max-w-4xl minus p-6, that is 848px ≈ 100ch at type-small — over the 65–75ch cap.Add max-w-prose to the descriptive type-small text-fg-muted paragraphs in PhoneChange.
1.11PhoneChange's no-phone branch = EmptyState tone="surface" inside AccountSectionCardNested card, banned. EmptyState tone="surface" is bg-surface + border-border-default — byte-identical chrome to the Card it sits in, so it renders as a bordered box floating in an identically-filled card. Its py-12 also makes the Teléfono card visibly taller than its siblings, and its title is type-h2 — the same role as the card title directly above it (two 16/600 headings stacked).Inline empty branch instead, mirroring the filled branch's own layout so the card is the same height either way: see §4.3.

1.12 Resulting geometry

mi-cuenta/layout.tsx
PageContainer inset="bleed" → flex flex-col gap-8 md:flex-row md:gap-12
aside shrink-0 md:sticky md:top-24 md:h-fit md:w-56
div min-w-0 flex-1 pb-16 ← the whole content track, no inner grid

Content track after the fix (0.8333 W − 224 − 48):

Viewport7681024128014401536≥1920
track36858179592810081328

2. cards-5 is broken inside this holder (Task 2.2)

CardGrid cols="cards-5" = grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5. Its breakpoints are viewport-keyed but the container is now a fraction of the viewport, so the column count outruns the available width at every step. Per-card widths as shipped:

Viewportcolscard width
7682180px
10243183px
12804185px
14404218pxmarginal
15365186px
19205250pxok

A MediaCard course tile at 185px has 153px of usable body after p-4: the kind-meta + Badge row wraps, the type-h2 title breaks at ~11 characters, and the full-width "Ver curso" button is narrower than its own label's comfortable measure. Certificate cards fail the same way (icon + Badge on one row). This is not a tuning problem — the recipe was sized for a full-bleed 1920 container where 5 columns of a 1600px inner track are ~300px each.

Decision — rename the recipe, don't add a fourth. cards-5 has exactly four call sites, all four of them these two pages + their loading.tsx. Nothing else uses it, so it is replaced, not supplemented (no dead recipe left behind).

// packages/ui/src/components/card-grid.tsx — cardGridVariants.cols
/** Account lists inside the mi-cuenta holder (courses, certificates): 1 → 4 columns. */
'cards-4': 'grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4',

Resulting card widths in the corrected track:

Viewport7681024128014401536≥1920
cols123344
card368278249293234314

Target floor for a MediaCard tile is ~240px (aspect-video band ≥135px tall, badge row on one line, ≥20 characters per title line). Every step clears it except the narrow 1536–1650 band (234–255px), which is the honest cost of a 4th column at 2xl. [EYES] — if 4-across at exactly 1536 reads cramped in the browser, the single-line fallback is to drop 2xl:grid-cols-4 and cap at 3 columns; do not invent a new breakpoint.

Both pages use the same variant. Course cards and certificate cards differ in height, not in minimum legible width (both are badge + title + one meta line), and a student flipping between two sibling pages should see one rhythm, not two.

2.1 Companion edits

  • apps/portal/src/app/(portal)/mi-cuenta/cursos/page.tsx<CardGrid cols="cards-4">
  • apps/portal/src/app/(portal)/mi-cuenta/certificados/page.tsx<CardGrid cols="cards-4">
  • .claude/rules/foundation-ui-conventions.md → update the card-grid.tsx inventory row (cards-3 / cards-4 / tiles-4) in the same PR, per the inventory rule.

3. The two sibling pages (Task 2)

3.1 Header treatment — one pattern, everywhere

Perfil's half-width header was the wrong call; it reverts. Reasons in 1.2. PageHeader already owns the only measure a header needs (max-w-prose on the subtitle, #1283), so cursos and certificados were already correct and perfil regressed away from them.

All five /mi-cuenta/* pages: <PageHeader size="section" title=… subtitle=… /> as a direct child of the content track, no wrapper, no span. Loading branches match (<PageHeader size="section" title=… subtitle="" loading />, already the case on cursos/certificados; perfil's loading.tsx drops its grid md:grid-cols-8 wrapper too).

3.2 Skeletons (Task 2.3)

Heights are derived from each card's real anatomy, not guessed as a pair.

Course tile (MediaCard): aspect-video band (0.5625 × column) + p-4 body carrying meta/badge row, type-h2 title (1–2 lines), lesson line, optional progress block, gap-4, full-width button. At a 293px column ≈ 165 + 206…230 = 371–395px.

Certificate tile (Card padding="md"): p-6 + icon/badge row + kind meta + type-h2 title (1–2 lines) + pinned date line ≈ 162–186px.

FilePropValue
cursos/loading.tsx<CardGridSkeleton …>cols="cards-4" count={4} itemClassName="h-96" label="Cargando…"
certificados/loading.tsx<CardGridSkeleton …>cols="cards-4" count={4} itemClassName="h-44" label="Cargando…"

h-96 = 384px (was h-64 = 256, 128px short — the skeleton collapsed on hydration). h-44 = 176px (was h-40 = 160; h-44 is also CardGridSkeleton's own default, kept explicit so the intent is readable). count={4} fills the 2xl row exactly and the lg rows cleanly.

3.3 Title casing sweep (microcopy)

The sidebar uses Title Case, the page headers Sentence case, and one page disagrees with itself on number: sidebar "Mis Facturas" vs page title "Mi factura".

Sentence case everywhere; the nav label and the page title are the same string:

RouteSidebar labelPageHeader title
/mi-cuenta/perfilMi perfilMi perfil
/mi-cuenta/cursosMis cursosMis cursos
/mi-cuenta/facturasMis facturasMis facturas (was "Mi factura", 3 call sites in facturas/page.tsx)
/mi-cuenta/certificadosMis certificadosMis certificados
/mi-cuenta/credencialMi credencialMi credencial

Principle: Familiarity — a nav item and the page it lands on must be the same word, or the student cannot tell they arrived.


4. Perfil — section order, grouping, titles (Task 3)

4.1 The order

#SectionTypeNotes
1PageHeader "Mi perfil"subtitle → "Tus datos, tu forma de ingresar y tus puntos." ("cartera" is internal vocabulary and no section is called that any more)
2Points banner (AccountPointsBanner)bannerbalance > 0 only; full width of the stack (§4.5)
3Datos personalesAccountSectionCardname · birth date · education level · gender
4Acceso y contactoAccountSectionCardemail (read-only) · phone (OTP flow)
5Datos de pagoAccountSectionCardStripe billing identity · saved cards
6Movimientos de puntosAccountSectionCardthe points ledger

Reads as: who I amhow I get in and how you reach mehow I paywhat I've earned. Identity to transactional, one direction, no backtracking.

4.2 Why "Teléfono" stops being its own card

The current page splits two credentials across two places: email lives inside "Datos personales" (read-only, "contact your school"), phone gets its own card. That is the real inconsistency — not that phone is separate, but that its twin isn't.

Merging phone into "Datos personales" (the other option) is worse: ProfileForm has a view/edit toggle and PhoneChange has a four-step OTP machine; one card with two independent editing modes is a state-matrix trap.

Decision: a single "Acceso y contacto" card holds both. Email and phone are the same category to the student (how the school reaches me / how I get in), each keeps its own affordance, and each card keeps exactly one edit mode.

Structure (Frontend):

  • ProfileForm drops the email dt/dd (view) and the email Input + note (edit). Its dl becomes 4 items = 2 clean rows at sm:grid-cols-2.
  • PhoneChange stops rendering its own AccountSectionCard in all four steps and returns its inner content only.
  • The page composes:
<AccountSectionCard title="Acceso y contacto">
<div>
{/* dt "Correo electrónico" / dd profile.email ?? '—' */}
<p className="type-small max-w-prose text-fg-muted">
Para cambiar tu correo, contacta a tu escuela — el cambio requiere verificación.
</p>
</div>
<div className="border-t border-border-subtle pt-4">
<PhoneChange currentPhone={profile.phone} />
</div>
</AccountSectionCard>

The Card's grid gap-4 already spaces the two blocks; the hairline marks the credential boundary without a second card. Accessible name comes from the card's own aria-label="Acceso y contacto".

This is the one item in the spec with real blast radius (it edits a form that saves). It is an IA call, which is the Designer's per design-routing.md, but it reverses a shipped grouping — see §7.

4.3 The no-phone empty branch (replaces the nested EmptyState)

Same shape as the filled branch, so the card doesn't change height when a phone is added:

<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="type-body text-fg-primary">Sin teléfono</p>
<p className="type-small mt-1 max-w-prose text-fg-muted">
Agrégalo para recibir avisos por WhatsApp y usarlo también como forma de ingresar.
</p>
</div>
<Button type="button" variant="outline" onClick={}>
<HugeiconsIcon icon={SmartPhone01Icon} size={16} strokeWidth={2} aria-hidden="true" />
Agregar teléfono
</Button>
</div>

No EmptyState, no nested surface, no second type-h2, no py-12 height spike. Copy is unchanged from what shipped.

4.4 "Más información" is deleted, not moved

Términos y condiciones · Aviso de privacidad · Preguntas frecuentes are already in TenantFooter, which renders on every /mi-cuenta/* page (DEFAULT_LEGAL_LINKS + DEFAULT_NAV_LINKS). The block inside "Datos de pago" is a duplicate of links that sit further down the same scroll, in a card about payment data, which has nothing to do with any of them.

Delete the SectionLabel + <ul> legal list from Datos de pago entirely. No relocation.

PointsInfoModal ("Acerca de los puntos"), stranded in that same footer block, moves to the points banner (§4.5) — it belongs next to the balance it explains.

4.5 Points banner

Currently max-w-lg (512px). Against a max-w-4xl (896px) stack of cards that is a visibly short first row — a ragged right edge among siblings that reads as a bug. The cheap alternative (narrow the whole stack to 512) starves the two-column dl.

Decision: the banner spans the stack (max-w-lg removed) and gains a trailing slot carrying the PointsInfoModal trigger, so the extra width is filled with something the student wants rather than with air:

[ 64px icon disc ] [ Tus puntos / 1,240 puntos ] …… [ Acerca de los puntos ]

Prop: AccountPointsBanner({ balance, action?: React.ReactNode }), the action rendered right-aligned (ml-auto shrink-0) inside the existing flex row. Everything else about the banner (gradient, disc, type-hero figure) is untouched.

Not changed, flagged: the banner's bg-linear-to-br from-warning-soft via-surface to-accent-soft blends two hue families across one surface. It is a José decision from 2026-09-04 and out of this issue's scope; noted so it is not mistaken for an oversight.

4.6 Phone reminder banner — off perfil

PhoneReminderBanner renders on exactly one page: perfil — the one page where its CTA ("Agregar teléfono" → /mi-cuenta/perfil) is a self-link, and where its body copy ("Agrega tu teléfono en tu perfil") instructs the student to go where they already are. It also duplicates, almost word for word, the message of the Acceso y contacto card's own empty branch two sections below, and its primary Button is a second accent moment on a screen that already spends one on the save action.

Decision: the nudge renders on the /mi-cuenta/* pages without the affordance — cursos, facturas, certificados, credencial — and not on perfil, where the affordance itself is the better prompt. Placement on those pages: directly under PageHeader, above the page's own content. No copy change needed; it becomes true again.

Secondary fix, wherever it renders: PhoneReminderBannerClient carries mb-6 while its parent already spaces the stack — remove mb-6, the stack owns the interval.

4.7 "Movimientos recientes" → "Movimientos de puntos"

Sitting directly beneath a card titled "Datos de pago", "Movimientos recientes" reads as payment movements. It is the points ledger. "Movimientos de puntos" — unambiguous in place, and it closes the loop with the balance banner at the top of the page.

It also becomes a real AccountSectionCard like its three siblings (today it is a bare SectionLabel + a Card-wrapped <ul> — a fourth title treatment and a fourth surface pattern on one page). Since the card supplies the surface, the list drops its own Card:

<AccountSectionCard title="Movimientos de puntos">
{entries.length === 0 ? (
<p className="type-small max-w-prose text-fg-muted">
Aún no tienes movimientos. Tus puntos aparecerán aquí cuando se confirme tu primer pago.
</p>
) : (
<ul className="-mx-6 divide-y divide-border-subtle border-t border-border-subtle">
{/* <li className="flex items-center justify-between gap-4 px-6 py-3"> … </li> */}
</ul>
)}
</AccountSectionCard>

-mx-6 + px-6 bleeds the row dividers to the card's edges (the card is padding="md" = p-6, so the two cancel exactly); the leading border-t separates the first row from the title. Row internals (icon tone, type-small reason, type-meta timestamp, type-mono amount) are unchanged.

4.8 Field labels

WhereShippedSpecWhy
Datos de pagoTitularkeepcorrect for a billing holder, parallel with "Correo de facturación"
Datos de pagoCorreo de facturaciónkeep
Datos de pagoCliente desdeFacturación desde"cliente" is commerce vocabulary on a surface that says alumno / escuela everywhere else; the value is when the billing record was created, which the new label states literally. Parallel with the label above it.
Datos de pagoTarjetas guardadaskeep (render only when non-empty — already true)
Datos de pago<Badge variant="success">Cliente activo</Badge>removeSignals nothing actionable — every student with a payment has one. Decorative status chip, banned by frontend-guardrails.md ("no decorative filler"), and the same "cliente" vocabulary problem.
All read-mode dttype-meta (12 / mono UPPER / 0.14em)type-label (11 / mono UPPER / 0.08em)type-label is the label role in the 10-role scale; type-meta is the meta/eyebrow role. More importantly the edit-mode labels in ProfileForm already use type-label, so the same field currently changes size and tracking when you press "Editar perfil". One role, no jump. Colour stays text-fg-muted.

All labels stay Sentence case, all are nouns, none end in a colon — already consistent, kept.


5. The corrected page shell (perfil)

<div className="max-w-4xl">
<PageHeader size="section" title="Mi perfil"
subtitle="Tus datos, tu forma de ingresar y tus puntos." />
<div className="flex flex-col gap-8">
<AccountPointsBanner balance={balance} action={earnRate > 0 ? <PointsInfoModal/> : null} />
<ProfileForm profile={profile} /> {/* Datos personales */}
<AccountSectionCard title="Acceso y contacto"></AccountSectionCard>
<AccountSectionCard title="Datos de pago"></AccountSectionCard>
<AccountSectionCard title="Movimientos de puntos"></AccountSectionCard>
</div>
</div>

The same max-w-4xl wrapper applies to the bind-gate, read-error and no-profile branches, so every state of the page shares one measure.

Vertical rhythm: PageHeader's own mb-8 (32px) → gap-8 (32px) between every section. One interval, top to bottom.

5.1 perfil/loading.tsx — layout-matched

Same shell, same widths, same 32px rhythm. Heights approximate each card's real anatomy:

SlotSkeleton
header<PageHeader size="section" title="Mi perfil" subtitle="" loading /> (no grid wrapper)
points bannerh-28 (112px)
Datos personalesh-72 (288px)
Acceso y contactoh-48 (192px)
Datos de pagoh-60 (240px)
Movimientos de puntosh-80 (320px)

Container keeps aria-busy="true" + aria-label="Cargando…"; Skeleton stays aria-hidden. [EYES] — these are derived, not measured; nudge on the Tailwind scale only (h-*), never to an arbitrary px value.


6. Accent budget + a11y check

  • Default view state: the points banner uses accent-soft/warning-soft tints, not bg-accent. PhoneReminderBanner — retained (José: "stays exactly where it rendered before"), not removed — contributes an accent moment WHEN IT RENDERS (an email-only student with no dismissal cookie): its PhoneReminderBannerClient CTA, labeled "Agregar teléfono", is a default Button (bg-primary/accent). Zero accent moments only once that banner has been dismissed or doesn't apply.
  • Transient: ProfileForm's "Guardar cambios" and PhoneChange's "Enviar código" are each the single accent moment of their own edit state. Opening both edit modes at once yields two — a tolerated edge, not a new pattern.
  • Card titles become h2 under the page h1; no skipped level (was h3).
  • Visible title doubles as the card's accessible name (AccountSectionCard's existing contract, kept).
  • Every helper paragraph gets max-w-prose; no line exceeds ~75ch at any viewport.
  • No new tokens are required by this spec. Everything above resolves to the shipped scale (gap-8 / gap-12, max-w-4xl, h-*, existing type-* roles, existing color tokens). The one primitive change is a rename + rebreakpoint of an existing CardGrid cva variant.

7. Open — José's call, not the Designer's

  1. "Acceso y contacto" (§4.2). Merging email + phone into one card is the correct IA, but it reverses a grouping shipped two days ago and edits a saving form. If rejected, everything else in this spec stands; the fallback is the current split (Datos personales with email → Teléfono), and only the §4.3 empty-branch fix applies to that card.
  2. Phone-reminder banner moves off perfil onto the other four /mi-cuenta/* pages (§4.6). The banner was deliberately moved out of the shared layout into per-page placement on 2026-09-03; this re-spreads it (still per-page, still not in the layout) and removes it from the one page it currently appears on.
  3. Points banner loses max-w-lg (§4.5). max-w-lg was set explicitly by José. The spec widens it to the stack and fills the gained width with the "Acerca de los puntos" trigger. If the narrow banner is preferred, the alternative is to keep max-w-lg and accept the ragged first row.
  4. Balance and ledger stay apart. The strongest IA move would be one "Mis puntos" section holding both the balance and its history, instead of a banner at the top and its ledger at the bottom with two unrelated sections between. Not specced, because top-of-page balance emphasis was an explicit José decision (#1556). §4.7's rename is the in-place mitigation.

8. Ratified 2026-09-06 (José, at implementation) — the "Open" items closed

The four §7 decisions were implemented as spec'd, with one rejection:

  1. "Acceso y contacto" (§4.2) — confirmed as spec'd. ProfileForm and PhoneChange merge ONLY their card chrome (one AccountSectionCard composed by the page, per §4.2's exact composition); their independent React state machines (ProfileForm's view/edit toggle, PhoneChange's four-step OTP machine) are untouched — neither component's internal logic changed, only what wraps them and where the email row lives.
  2. Phone-reminder banner moves off perfil (§4.6)rejected. The banner stays exactly where it renders today: /mi-cuenta/perfil only, nowhere else. José's call: the banner is useful precisely because perfil is where the affordance to fix it lives; spreading it, CTA-less, across the other four pages was judged not worth the extra surface. Only the spacing bug named alongside the move (the banner's own mb-6, redundant against the stack's gap-8) was still fixed, since it renders unchanged on perfil.
  3. Points banner loses max-w-lg (§4.5) — confirmed as spec'd, including the action slot for PointsInfoModal's "Acerca de los puntos" trigger.
  4. Balance and ledger stay apart — confirmed as spec'd (not a decision to revisit here); only §4.7's rename ("Movimientos de puntos") landed, as the spec itself proposed as the in-place mitigation.

Everything else in §1–§6 (the layout rework, the cards-5cards-4 CardGrid rename, the title casing sweep, the PhoneChange no-phone empty-branch fix, the AccountSectionCard title fix, the type-metatype-label read-mode dt sweep) landed as spec'd. Implementation: apps/portal/src/app/(portal)/mi-cuenta/**, apps/portal/src/components/{account-section-card, phone-change,profile-form,account-points-banner,phone-reminder-banner-client}.tsx, packages/ui/src/components/card-grid.tsx.


Mobile spec — 2026-09-06 (José: "define mobile specific UX and UI elements")

§1–§8 were reasoned at desktop width; below md the section only ever inherited whatever Tailwind's breakpoint collapse produced. This section designs the phone experience (360–430px) as its own thing. Same method as §0: no browser — every number is computed from the JSX, the cva variants, and the .client.editorial CSS overrides. Items needing eyes-on are marked [EYES].

9. The mobile geometry (what everything below rests on)

Two portal-level rules govern the student surface (<body class="client editorial">) and change all of §0's arithmetic on a phone:

SourceEffect
portal-theme.css:703 .client.editorial [data-slot='page-container']padding-inline: max(1.5rem, 8.3333%) — beats the inset variant class
portal-theme.css:710 .client.editorial [data-slot='button']:not(icon…)every non-icon Button is already 44px tall, padding-inline: 24px
portal-theme.css:907 .client.editorial [data-slot='input']every Input is 44px tall, padding-inline: 16px, font-size: 14px

So the page gutter on a phone is the larger of 24px and 8.3333%, i.e. the percentage always wins there: 30px at 360 · 32.5px at 390 · 35.8px at 430. The formula was designed for a 1/12-column desktop margin; on a phone it inverts the intent — the smallest screen gets the biggest gutter. Measured today:

Viewportgutter (each side)content trackinside a padding="md" card
36030300252
39032.5325277
43035.8358310

252px of usable width at 360 is what breaks the two things below.

9.1 Two confirmed overflows at 360px (not cosmetic — real horizontal breaks)

RowMinimum width it needsAvailableVerdict
OtpCodeInput (phone-change step 3)6 × min-w-10 (240) + separator (px-1 + w-3 = 20) + 2 × root gap-2 (16) = 276px252overflows 24px
ProfileForm edit footer ("Guardar cambios" + "Cancelar")≈115+32 and ≈62+32 labels at the portal's 24px button padding, + gap-2 = ≈281px252overflows 29px

Both are hard breaks: Button is shrink-0, and the OTP slots carry min-w-10, so neither row can compress — they push past the card, and overflow-x: clip on the page then silently cuts the last slot / the Cancelar button. This is the single worst mobile defect in the section.

9.2 iOS zoom-on-focus (portal-wide, surfaced here)

.client.editorial [data-slot='input'] { font-size: var(--text-small) } = 14px. Mobile Safari auto-zooms the viewport whenever a focused text field computes below 16px — so every field in the portal (this section, checkout, auth, registro) yanks the page on focus and leaves the student zoomed in. It is a one-line token-layer fix, below.


10. The mobile navigation decision (the headline)

Below md, mi-cuenta/layout.tsx stacks: AccountSidebar's five full-width rows render in normal flow above {children}. Five 38px rows + separator + the layout's 32px gap ≈ 230px of nav before the page title — on a 640pt-tall phone that is a third of the first screen, on every one of the five pages, spent on links the student did not come for.

10.1 Options weighed

OptionWhy not / why
Segmented primitiveWrong primitive. It renders <button role="tab" aria-selected> with a measured sliding thumb, is w-fit self-start (never full-width, never scrollable), and has no asChild/link path. Navigation must be real <a href> with aria-current="page" — turning routes into role="tab" buttons kills open-in-new-tab, middle-click and next/link prefetch, and misuses the tab role (tabs switch panels in a page, not routes). Reject.
Bottom tab barReject. It would appear only inside /mi-cuenta — a bar that materializes for one section and vanishes elsewhere is disorienting, and it claims "these 5 are the app's top-level destinations" when the real top level (Tienda / Nosotros / Contacto / carrito / cuenta) lives in the sticky TenantHeader. It also permanently covers content on long forms and sits exactly where iOS Safari's own bottom chrome and the keyboard accessory bar are.
Hamburger / drawerReject. Adds a tap before every switch for only 5 destinations, hides the map of the section (Agency), and the header already owns a hidden overflow (MobileNav's "…" DropdownMenu). Two hidden menus stacked on one screen is worse than one visible rail.
Nav list moved BELOW the contentReject. Zero-cost to build, but on perfil (banner + 4 cards) the switcher ends up ~2500px down; "where am I / where else can I go" becomes unanswerable without a long scroll.
Horizontal rail of the same linkschosenKeeps all five destinations visible-or-one-swipe-away with their real labels, keeps aria-current, costs one 44px row instead of 230px, and is the same five links in the same order with the same active language as the desktop sidebar — one nav, two arrangements, not a second nav.

Decision — one component, two arrangements. AccountSidebar keeps its single item list and renders a vertical list at md+ (unchanged) and a horizontal, edge-bled, scrollable rail below md. No useMediaQuery (its SSR snapshot is false and would flash the wrong arrangement) — pure responsive classes, so the server render is already correct.

The five labels total ≈730px with icons, so the rail does scroll at phone widths. That is accepted deliberately: partial visibility of item 3 at the cut edge is itself the affordance (no gradient mask, no arbitrary mask-image value), and the active item is scrolled into view on mount so the rail never lies about where you are.

10.2 The markup (Frontend implements)

apps/portal/src/components/account-sidebar.tsx:

<nav
aria-label="Mi cuenta"
// Bleed to the true viewport edges so the first item lines up with the content and the last one
// scrolls off the screen edge instead of dying in a padded dead zone. `px` restored INSIDE the
// scroller so the focus ring on item 1 isn't clipped by the overflow.
// `py-1`: `overflow-x-auto` computes overflow-y to `auto`, which would clip the 3px focus ring
// vertically and spawn a phantom scrollbar — 4px of room prevents both.
className="-mx-(--inset-bleed) overflow-x-auto border-b border-border-subtle px-(--inset-bleed) py-1 pb-3 md:mx-0 md:overflow-visible md:border-0 md:px-0 md:py-0 md:pb-0"
>
<ul className="flex w-max items-center gap-2 md:w-full md:flex-col md:items-stretch md:gap-1">
<li>{/* Mi perfil */}</li>
{/* separator: horizontal rule on desktop, dropped on the rail — a divider between item 1 and
item 2 of a horizontal strip is noise, and the grouping it expresses is weak */}
<li
aria-hidden="true"
className="hidden md:my-2 md:block md:border-t md:border-border-subtle"
/>
{/* the four record links */}
</ul>
</nav>

Link class (one string, both arrangements — min-h-11 is the portal's established 44px touch floor, already used by tenant-chrome/mobile-nav.tsx):

type-small flex min-h-11 shrink-0 items-center gap-3 rounded-md px-3 md:min-h-0 md:py-2

shrink-0 matters: without it the flex rail would squeeze labels instead of scrolling.

Active-into-view, in the existing client component (it already holds usePathname):

// Instant, not smooth: this is a mount-time correction of a wrong initial scroll position, not a
// transition the student initiated — animating it would draw the eye to a bug-fix. Also makes the
// behaviour identical under prefers-reduced-motion with no branch.
useEffect(() => {
activeRef.current?.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'auto' })
}, [pathname])

10.3 State matrix (unchanged language, both arrangements)

StateTreatment
resttext-fg-secondary
hover (pointer)hover:bg-sunken hover:text-fg-primary, colors only
activebg-sunken text-fg-primary + aria-current="page"
focus-visiblethe primitive ring (focus-visible:ring-3 ring-ring/50) — must be added; the current links have no focus style at all
disabledn/a

Motion: none beyond the existing transition-colors. A nav a student hits several times a session gets no entrance/slide animation (frequency rule), and the rail must not animate its own scroll position.

Not sticky. The TenantHeader is already sticky at 80px; a second pinned 52px row would eat 20% of a 640pt viewport for a control used once per visit. The rail scrolls away with the page.

10.4 Layout change

mi-cuenta/layout.tsx — the stacked gap drops, because the nav is now a 44px band, not a 230px block, and it reads as chrome for the column below it:

flex flex-col gap-4 md:flex-row md:gap-12 (was gap-8 md:gap-12)

The aside keeps shrink-0 md:sticky md:top-24 md:h-fit md:w-56 verbatim — every mobile class lives inside AccountSidebar, so the layout diff is one token.


11. The mobile gutter — --inset-bleed gets a phone step

Decision: below md, the bleed inset is 16px (1rem, the standard mobile gutter); the 1-of-12 column resumes at md, where the two-column layouts appear anyway. This gives the phone back 28px of content (300 → 328 at 360px) and makes the token honest: small screens get the small gutter.

The step at md (16px → 64px) is deliberate and invisible in practice — md is exactly where the sidebar appears and the whole page re-forms; nobody crosses it on a phone.

Two files, because the portal currently bypasses the token with a scoped rule:

/* packages/ui/src/tokens/theme.css — after the :root block that declares --inset-bleed */
/* Mobile gutter (#1631 mobile spec): below md the 8.3333% column outruns the 24px floor (30px at
360, 36px at 430) — on a phone the gutter should be the SMALLEST, not the largest. */
@media (width < 48rem) {
:root {
--inset-bleed: 1rem;
}
}
/* apps/portal/src/app/portal-theme.css:703 — consume the token instead of restating its value, so
the mobile step above actually reaches every portal page container (and so the nav rail's
`-mx-(--inset-bleed)` cancels the padding exactly at every width). Identical at md+. */
.client.editorial [data-slot='page-container'] {
padding-inline: var(--inset-bleed);
}

Both are token-layer CSS (Designer write surface). No new token is introduced — an existing one gains a breakpoint step. Everything else that reads px-(--inset-bleed) by hand (tenant header, footer, announcement bar, ai-diagnostic-section) inherits the same phone gutter, which is the intent: one gutter, whole surface.


12. Touch, fields and control density

The portal already ships 44px buttons and inputs (§9). What is left:

#ItemDecision
12.1Input font size (portal-theme.css:910, font-size: var(--text-small) = 14px)Below md, fields are 16px — the threshold that stops mobile Safari zooming the viewport on focus. @media (width < 48rem) { .client.editorial [data-slot='input'] { font-size: 1rem } }. 1rem in the token layer is the sanctioned home for a type number with no type-* role. Portal-wide win, not just this section.
12.2Button inline padding (portal-theme.css:721, 24px)Below md, padding-inline: 1rem. 24px per side on a 296px card is 33% of a two-button row's width and is what pushes §9.1's edit footer over the edge. Height stays 44px.
12.3Card paddingAccountSectionCardp-4 sm:p-6 (className on the Card; twMerge beats the padding="md" cva value). 24px of padding inside a 328px card is disproportionate; 16px is the mobile standard and buys back 16px of content. Follow-through: the "Movimientos de puntos" full-bleed list must track it — -mx-4 px-4 sm:-mx-6 sm:px-6 on the <ul>/<li>, or the row dividers stop meeting the card edge.
12.4OTP slot floorOtpCodeInputmin-w-9 sm:min-w-10 on all six slots (36px floor on a phone, 40px from sm). Row minimum drops 276 → 252px, clearing the 296px card with 44px to spare. Slot height stays h-10; the whole row is one field, so tapping anywhere focuses it.
12.5OTP field font sizeAdd className="text-base" to the InputOTP inside OtpCodeInput — that className lands on the library's real (hidden) <input>, which is what iOS measures for zoom. [EYES] — the inherited size may already be ≥16px; the class is harmless either way.
12.6FormFooterActions buttons (portal-theme.css:735 resets them to 32px)Leave the 32px height (the row already flex-wraps and these are secondary text actions), but they are the only sub-44px targets left in the flow. [EYES] — if "Reenviar código (30s)" feels hard to hit on a real phone, the fix is min-height: 2.75rem inside that same scoped rule below md, not a call-site class.
12.7"Acerca de los puntos" trigger (PointsInfoModal)It is a TextLink-wrapped <button>, so the 44px button sweep never touches it — the tap target is the ~22px text box. Add min-h-11 items-center sm:min-h-0 to its trigger className.
12.8Header icon buttons (size="icon" 32px, excluded from the sweep)Out of this issue's scope, flagged: the site header's cart/overflow/profile controls are 32–36px on phones. Separate pass.

13. Per-file mobile changes

Resulting phone geometry after §11 + §12.3: 328px track · 296px card interior at 360px.

FileChange
components/account-sidebar.tsx§10.2 in full — the rail, the shared link class with min-h-11 md:min-h-0 + shrink-0 + a focus ring, the hidden md:block separator, the active-into-view effect.
mi-cuenta/layout.tsxgap-8gap-4 in the stacked direction (§10.4). Nothing else.
components/account-section-card.tsxp-4 sm:p-6 (§12.3). Title row hardening: flex flex-wrap items-center justify-between gap-x-4 gap-y-2, min-w-0 on the <h2>, and the action wrapped in a shrink-0 span. Today no call site passes action, so nothing is visibly broken — but at 296px a type-h2 title (~180px) plus a Badge (~90px) plus gap-4 is 286px, i.e. one badge away from squashing. Wrap instead of squash.
components/profile-form.tsxRead mode dl and edit mode already collapse to one column below sm — correct, no change. The edit footer stops overflowing via §12.2. Recommended (§14.1): the field pairs should key off the CARD, not the viewport — @lg:grid-cols-2 under an @container — because the pairs are cramped at md, not on the phone.
components/phone-change.tsxNo structural change: both collapsed rows are already flex-wrap items-center justify-between gap-3 (the Button drops to its own line under 296px). The PhoneInput at sm:max-w-xs is full-width below sm — correct. Fixed by §12.4/12.5 for the OTP step and §12.1 for the field size.
components/account-points-banner.tsxStacks below sm. At 296px the row needs 64 (disc) + 20 (gap) + ~160 (type-hero figure) + ~150 (action) ≈ 394px. Restructure: outer flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-5 p-4 sm:p-6; disc + text stay a nested flex items-center gap-4 sm:gap-5; the action keeps sm:ml-auto sm:shrink-0. Gradient, disc size and type-hero figure unchanged — it is the page's one visual moment.
mi-cuenta/perfil/page.tsxNo layout change (max-w-4xl is inert below md). Ledger bleed follows §12.3.
mi-cuenta/cursos/page.tsx · certificados/page.tsxCardGrid cols="cards-4" is grid-cols-1 until lg → correct one column at every phone width; a MediaCard at 328px has a 328×184 media band and 296px of body. No change. See §14.2 for the tablet band.
*/loading.tsxUnchanged — the skeleton heights (h-96 course / h-44 certificate / the perfil stack) still match the real anatomy at one column.

  1. Container queries for the field pairs. ProfileForm's sm:grid-cols-2 keys off the viewport, but the card's width is non-monotonic: 560px inner at 767 (stacked), collapsing to 320px at 768 the moment the sidebar appears, back to 533 at 1024. So two columns are right on a small tablet and wrong just past it — at 768 a type-label like "ÚLTIMO GRADO DE ESTUDIOS" (~168px) does not fit its 152px column. The correct tool is a container query on the card (@container on AccountSectionCard, @lg:grid-cols-2 on the dl — two columns only above a 512px card), which is Tailwind v4 core, needs no plugin, and stays statically analyzable. Same class count, right axis. This is the same bug §2 fixed for CardGrid by re-breakpointing — container queries would have fixed it at the root.
  2. The 640–1023 tablet band for cards-4. lg:grid-cols-2 means a single course card stretches to the full 735px track at 767px wide (stacked, no sidebar) — one enormous card per row on a tablet in portrait. Fixing it properly is the same container-query move (@md:grid-cols-2 @3xl:grid-cols-3 @5xl:grid-cols-4) on the shared CardGrid variant. Flagged, not specced, because it re-opens a primitive José ratified in §8 two days ago.
  3. Whether the phone gutter is 16px or 24px (§11). 16px is the standard mobile gutter and what this spec assumes; 24px keeps the original "floor" intent and costs 16px of content. One-line swap either way.
  4. FormFooterActions sub-44px targets (§12.6) — kept compact deliberately; needs a real phone to judge.
  5. Header icon controls at 32–36px (§12.8) — a portal-wide touch pass, not this section's.

15. Implemented 2026-09-06 (Frontend) — the mobile spec landed

José reopened §14 items 1 and 2 at implementation time (both were "Recommended, not specced" — now decided) and confirmed the §11 gutter value as 16px. Landed:

  • §10 mobile navAccountSidebar renders the horizontal scrollable rail below md exactly per §10.2 (account-sidebar.tsx); mi-cuenta/layout.tsx's stacked gap drops to gap-4 (§10.4).
  • §9.1 the two 360px overflows — closed via §12.2 (button inline padding, portal-wide token-layer media query) for ProfileForm's edit footer, and §12.4/§12.5 (OTP slot floor + text-base) for OtpCodeInput. FormFooterActions itself (§12.6) is untouched, as specced.
  • §9.2/§12.1 iOS zoom — the shared [data-slot='input'] rule steps to 1rem below md.
  • §11 mobile gutter — both files fixed as specced: the --inset-bleed token gains its < md step (packages/ui/src/tokens/theme.css), and the portal's page-container rule now CONSUMES the token instead of restating its value (apps/portal/src/app/portal-theme.css).
  • §14.1 container queries for field pairs — REOPENED, now decided. AccountSectionCard is the @container ancestor (container-type: inline-size via the @container utility on its Card frame); ProfileForm's two sm:grid-cols-2 pairs (the read-mode dl and the edit-mode birth-date/education-level pair) both switch to @lg:grid-cols-2 (the container-query lg = 512px, matching the spec's own floor exactly — no arbitrary value needed). "Datos de pago" has no equivalent 2-column grid today (it renders a single-column dl), so no change was needed there.
  • §14.2 the tablet band for cards-4 — REOPENED, now decided. CardGrid's cards-4 variant switched to the spec's own literal recommendation — @md:grid-cols-2 @3xl:grid-cols-3 @5xl:grid-cols-4 — over lg:/xl:/2xl:. The single @container ancestor both mi-cuenta list pages (cursos, certificados) now share is mi-cuenta/layout.tsx's content column (added once there, not per-page) — zero changes needed at either call site, as the spec anticipated.

Not touched (explicitly out of scope, per the routing brief): FormFooterActions's own 32px button height (§12.6) and the header's 32–36px icon controls (§12.8) — both still flagged, still open for José.


16. Implemented 2026-09-06 (Frontend) — live-annotation polish round

A batch of Agentation feedback across the four /mi-cuenta/* pages, implementation-only (no new visual decisions — tweaks with existing tokens, per design-routing.md). Landed:

  • Sidebar Card aspect + sticky offset. AccountSidebar wraps its md+ column in the neutral Card (zero footprint below md, chrome only at md:, so the mobile rail — §10 — is untouched); mi-cuenta/layout.tsx's sticky offset moved top-24top-28 (112px), a clean 32px gap below the header's actual h-20 (80px — the offset's own comment previously cited a stale 72px figure from before the header grew, #1556).
  • Course-interior back link + date spacing. BackLink size="lg" replaces the inline default on /cursos/[enrollmentId] (screen-corner placement per BackLink's own contract). The "Inscrito el 3 sep 2026" missing-space bug was formatDate/formatDateTime (@tedos/core/student) emitting a narrow/regular no-break space (U+202F/U+00A0) instead of a plain space in some ICU builds — both now normalize to ASCII space post-format.
  • "Acceso y contacto" symmetry. The email row gets the SAME flex flex-wrap items-center justify-between shape as PhoneChange's rows, a max-w-md cap on its dl (narrower than the explainer paragraph's existing max-w-prose), and a disabled "Cambiar correo" trigger + a visible "Próximamente" caption (same pairing as the Facturar button below) — email-change isn't wired, so it stays visually present but inert, matching the phone row's affordance instead of standing bare beside it.
  • Points banner whole-card click. Landed straight from §19.1/§19.2 (the Designer's spec below landed mid-implementation) rather than a hand-rolled div role="button": AccountPointsBanner splits into two explicit variants — the static <div> (no dialog, earnRate <= 0) and AccountPointsBannerButton (a real <button type="button">, composed as PointsInfoModal's DialogTrigger asChild child via its new optional children trigger slot). Zero hand-rolled a11y — focusability, Enter/Space, and aria-haspopup/aria-expanded/data-state all come from the platform + Radix. The former nested "Acerca de los puntos" trigger is gone (a <button> can't contain another interactive control); its text is now the inert hint slot. Only the mechanism from §19/§21.5 landed here — the hue/anatomy/motion/empty-state redesign (§17, §21.2– 21.4) is out of this pass's scope, left for a follow-up implementation of that spec.
  • Cursos grid cap + view switcher. CardGrid gains a cards-3-holder variant (container-query 1→3, same mechanism as cards-4 minus its last step) — certificados keeps cards-4 unchanged. A new CursosView client leaf (@/components/cursos-view.tsx) adds a Segmented grid/list toggle over the already-fetched course list; list mode uses ListRow (course title/kind/state/enrolled-date, no persistence). shadcn registry checked: toggle-group fits a generic multi-select, not a single in-page mode switch — the existing Segmented primitive (already role="tablist"/role="tab") is the better, already-adopted fit.
  • Mis Pagos rename. /mi-cuenta/facturas's PageHeader title and the matching account-sidebar.tsx nav label both renamed "Mis Facturas" → "Mis Pagos" (route segment unchanged). A per-row "Facturar" column is CODE-GUARDED behind a BILLING_CONNECTED = false constant (real, type-checked JSX, not a {/* ... */} text comment — keeps lint/typecheck clean while dead) until the Stripe billing connection lands (no tracked issue number found for it).
  • ProfileForm "Nombre completo" overflow. Read-mode value gets max-w-xs truncate so an outlier-length name ellipsizes instead of stretching the @lg:grid-cols-2 field-pair grid.

Points banner — Design Spec, 2026-09-06 (José, live review: "design a better banner, more fun")

Scope: apps/portal/src/components/account-points-banner.tsx only. Everything §1–§15 decided about the perfil page — the max-w-4xl measure, the gap-8 stack rhythm, the banner spanning the full stack width (§4.5, ratified §8.3), the <sm stack (§13), the mobile gutter — stands unchanged and is not re-litigated here. Same method as the rest of this doc: no browser, every value read off the JSX + the token layer; eyes-on items marked [EYES].

Rubric: knowledge/design-principles.md. Tokens: .claude/rules/foundation-ui-conventions.md + the portal's .client.editorial scope (apps/portal/src/app/portal-theme.css). Motion: G6 + grid-system.md C3/C4. Composition: react-style-guidelines.md E1/E2/E4, F1.

16. Diagnosis — why the current banner reads as a data row

#ShippedWhy it doesn't land
16.1bg-linear-to-br from-warning-soft via-surface to-accent-softCrosses two hue families on one surface (amber → white → cyan) — already flagged in §4.5 as inherited, not designed. Worse, to-br puts the cyan end exactly under the trailing action, tinting it, so the card's warmest point is its top-left corner and its coldest is the thing you want clicked.
16.2size-16 rounded-lg filled square, 36px glyphThe strongest shape on the card is the icon container, not the number. A filled square tile reads as an app-icon/nav affordance; a reward reads as a disc/seal.
16.3type-hero figure (44px in .editorial) + inline type-h2 unit44px is the page-title role — the same weight the student just read at the top of the page. The balance never gets to be the loudest thing on its own card.
16.4No motion at allIts sibling CoursePointsBanner — a smaller moment (points you might earn) — has a delayed entrance. The banner reporting what the student has actually earned is the static one. Backwards.
16.5The number is the only information"1,240 puntos" is abstract. The one fact that turns a number into a reward — what it is worth — lives two clicks away inside PointsInfoModal. Purpose: the surface states data, not value.
16.6Whole card inert; only a ~22px text link is clickable (also §12.7's flagged sub-44px target)José's second ask. Covered in §19.

Not a defect, kept: SparklesIcon (rewards, never a coin/money glyph — José's call on CoursePointsBanner) and the amber family as the "puntos" identity (§17.1).

17. The visual spec

17.1 Hue — one family, amber, and why it is not swapped

--color-warning (#b45309) is the only yellow/amber-family token the foundation ships, and CoursePointsBanner already claims it for "puntos". Splitting the two banners across hues would break Familiarity for a student who sees "earn 240 points" on a course page and "you have 1,240 points" here. Decision: amber stays, and the cyan end of the gradient goes — the banner becomes single-family warm, which is also what makes the accent accounting trivially clean (§18).

The alternative — coral, the .editorial scope's --contrast / --contrast-container / --contrast-on-container MD3 "tertiary" trio — is semantically the better token family (it is an explicit counter-accent, not a status hue being borrowed) and is flagged in §20.1, not spec'd: it changes a hue José set on 2026-09-04 and would desynchronize the two banners.

No new token is introduced by this spec. Everything below resolves to shipped values:

TokenWhereValue source
--color-warningmedal glyph, type-label eyebrowfoundation theme.css (#b45309)
--color-warning-softmedal fill, hover wash, the radialfoundation (8% amber)
--tone-amber-soft / -edgemedal halo ring / card hairlinefoundation :root (8% / 22% amber)
--color-surfacecard base under the wash.client.editorial (#ffffff)
--color-fg-primary/-secondary/-mutedfigure / worth line / hint.client.editorial navy family
--ringfocus ring.client.editorial navy (≈14:1 on canvas)
--ease-out, --duration-hover/-pressall transitionsfoundation + .client

17.2 Anatomy

┌──────────────────────────────────────────────────────────────────────────┐
│ ╭────╮ TUS PUNTOS │
│ │ ✦ │ 1,240 puntos Acerca de los puntos → │
│ ╰────╯ Equivalen a $1,240 en tu próxima compra. │
└──────────────────────────────────────────────────────────────────────────┘
medal figure block hint (right-aligned, sm+)
PartSpec
rootrounded-xl border border-(--tone-amber-edge) bg-surface p-4 sm:p-6 · data-slot="account-points-banner" · layout flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6 (§13's stacked rule, kept)
medalsize-14 shrink-0 rounded-pill bg-warning-soft text-warning ring-8 ring-(--tone-amber-soft) · data-slot="account-points-medal" · SparklesIcon size={28} strokeWidth={2} aria-hidden
eyebrowtype-label text-warning — "Tus puntos" (unchanged copy)
figureflex items-baseline gap-2: balance type-display text-fg-primary (52px, tabular not needed — the role is --font-sans), unit "puntos" type-body text-fg-muted
worthtype-small text-fg-secondaryEquivalen a {redeemValue} en tu próxima compra. Rendered only when redeemValue > 0
hintsm:ml-auto shrink-0 flex items-center gap-2 type-small text-fg-muted — text + ArrowRight01Icon size={16} strokeWidth={2} aria-hidden. Static text, never a nested control (§19)
text blockflex min-w-0 flex-col gap-1

Three deliberate changes from shipped:

  1. rounded-lg square → rounded-pill disc (R5) at size-14 + ring-8 amber halo. A circle with a soft glow reads as a seal/medal; a filled square reads as an app tile (16.2). Footprint 56 + 2×8 = 72px, i.e. the same optical mass the old 64px tile had, with the weight moved from fill to halo. G19 concentricity is satisfied by construction (a pill has no corner to match).
  2. Figure type-herotype-display (44 → 52px). --text-display is the one role .client.editorial does not bump, so 52px is the same number on every surface. Against the page's type-section title (30px) that is a 1.73× step — the balance is unambiguously the loudest thing on the page, which is the whole point of the banner (16.3). No responsive step: type-* are @layer components classes, so sm:type-display does not exist — and at 296px of card interior a 6-character figure at 52/600 measures ≈180px, so it fits the phone unaided.
  3. The worth line is new copy. balance × pointValue, formatted es-MX currency. This is the single biggest "you're earning something good" move on the card and it costs no pixels of ornament — Purpose over decoration. Value is already on the page (pointValue), so it is a prop, not a fetch.

17.3 Background — the wash moves to the token layer

The diagonal two-family gradient is replaced by a radial amber wash anchored on the medal, so the warmth reads as emanating from the reward rather than as a coat of paint. It ships as a portal-theme.css rule (Designer write surface, same pattern the file already uses for [data-store-grid]) rather than an arbitrary bg-[radial-gradient(…)] in TSX, which would be a banned arbitrary value:

/* ── Points banner (#1631 banner pass) — the warm reward wash.
Single-family amber emanating from the medal at the left edge; replaces the shipped
warning→surface→accent diagonal, which crossed two hue families and tinted the trailing
content cyan. A background-IMAGE over the element's own `bg-surface`, so the hover /
dialog-open background-COLOR transition still reads through it. ── */
.client.editorial [data-slot='account-points-banner'] {
background-image: radial-gradient(
120% 140% at 6% 50%,
var(--color-warning-soft) 0%,
color-mix(in oklab, var(--color-warning-soft) 40%, transparent) 38%,
transparent 72%
);
}

[EYES] — the 6% 50% anchor is tuned for the sm+ row. In the <sm stack the medal sits top-left, so the wash centre lands slightly low; if that reads off on a phone, the one-line fix is a @media (width < 40rem) override of the position to 6% 22%, nothing else.

17.4 Motion

Three beats, all transform/opacity (G6), all reduced-motion-guarded (C4). Frequency check (emil-design-eng): /mi-cuenta/perfil is visited occasionally, not tens of times a day — an entrance is warranted; nothing here is on a keyboard-initiated path.

BeatTriggerParamsWhy
Entrancemountanimate-in fade-in slide-in-from-bottom-2 duration-300 ease-outno delay8px lift + fade. No delay-1000 like CoursePointsBanner: that banner is a reveal in a sidebar; this one is the first thing on the page and a held-back first row reads as a broken load.
Medal glint700ms after mount, oncetranslateX(-160% → 320%) skewX(-18deg), 900ms --ease-out, fill-mode: bothThe one flourish. Contained to a 56px disc so it cannot read as page noise; fires after the entrance settles, never loops. This is the "you earned something" beat.
Hover / presspointercard hover:bg-warning-soft (colors, --duration-hover 140ms --ease-out) · medal group-hover:scale-105 · hint arrow group-hover:translate-x-1 · medal group-active:scale-95 (--duration-press 120ms)The card is now a control (§19) and must answer the pointer. No scale on the card itself — scaling a 900px-wide card exposes the canvas at its edges and reads as a glitch; the medal is the pressable-feeling element.

Explicitly rejected: a JS count-up on the balance. It forces a client leaf onto a server-renderable component, re-fires on every navigation back to the page, and animates a number the student is trying to read. Delight that costs correctness is not delight (Craft).

Glint + reduced-motion guard, same token-layer file:

/* The one flourish: a single light sweep across the medal, once, ~0.7s after the card lands.
transform-only (G6), contained to the 56px disc. White at low alpha is a raw value in the
sanctioned token layer only — same as `--color-ink-fg-muted` above. */
@keyframes points-medal-glint {
from {
transform: translateX(-160%) skewX(-18deg);
}
to {
transform: translateX(320%) skewX(-18deg);
}
}
.client.editorial [data-slot='account-points-medal'] {
position: relative;
overflow: hidden;
}
.client.editorial [data-slot='account-points-medal']::after {
content: '';
position: absolute;
inset-block: -25%;
inline-size: 45%;
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.55), transparent);
animation: points-medal-glint 900ms var(--ease-out) 700ms 1 both;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.client.editorial [data-slot='account-points-medal']::after {
animation: none;
opacity: 0;
}
}

The entrance needs no extra guard — motion.css already kills .animate-in under prefers-reduced-motion. The hover/press scales are transition-driven, so reduced-motion users still get the colour change; if that reads as too much movement, the same media query drops transform: none on the medal. [EYES]

17.5 State matrix

StateTreatment
restbg-surface + the amber radial (17.3); border-(--tone-amber-edge); medal bg-warning-soft + ring-8 ring-(--tone-amber-soft), glyph text-warning
hover (pointer)root hover:bg-warning-soft; medal scale-105; hint arrow translate-x-1 — all 140ms --ease-out
focus-visibleoutline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-offset-2--ring is navy in .editorial (≈14:1 on canvas), never the cyan accent
active / pressmedal group-active:scale-95, 120ms (--duration-press). Card itself does not scale
dialog opendata-[state=open]:bg-warning-soft — Radix writes data-state on the trigger, so the card holds the hover wash while its dialog is up instead of snapping back to rest
loadingperfil/loading.tsx — the banner skeleton grows h-28h-40 (§17.6)
empty (balance ≤ 0)renders nothing. Unchanged, and now gated by the PAGE (§19.2) rather than by an early return. Zero-balance treatment is flagged in §20.2
errorn/a — a server read; the page's !ok branch owns it (ReadError)
disabledn/a — a banner is never disabled. When the points program is off (earnRate <= 0) the page renders the static variant, which is not a control at all (§19.2)
reduced motionentrance + glint off; hover/press = colour only

17.6 Loading branch (perfil/loading.tsx)

Derived from the new anatomy at p-6: label 16 + gap-1 4 + figure ≈57 (52 × 1.05 leading) + gap-1 4 + worth ≈23 = 104, + 48 padding = ≈152px. Skeleton steps h-28 (112) → h-40 (160). Same reasoning as §5.1 — nudge on the Tailwind scale only, never to an arbitrary px value. [EYES]

18. Accent-budget accounting

The banner does not become the screen's accent moment, and the screen's rest-state budget stays at zero. Counting per foundation-ui-conventions.md (surfaces resolving to --color-accent):

Element on /mi-cuenta/perfilFamilyAccent count
Points banner (this spec)warning + neutral surface/fg0
ProfileForm "Editar perfil"Button variant="outline"0
PhoneChange "Cambiar / Agregar teléfono"Button variant="outline"0
PhoneReminderBannerunchanged from §8.20
Rest-state total0
  • This spec improves the count: the shipped to-accent-soft gradient stop was the banner's one reference to the accent family (§6 read it as zero because it is a 10% tint, not a bg-accent surface — a defensible but borderline call). Removing it makes the reading unambiguous rather than argued.
  • The focus ring resolves to --ring, which .client.editorial sets to navy, not the brand cyan — deliberately, per the scope's own note (cyan measures ≈2.2–2.6:1 on the canvas, under the 3:1 non-text bar). So even the focus state spends no accent.
  • Transient states are unchanged from §6: ProfileForm's "Guardar cambios" and PhoneChange's "Enviar código" are each the single accent moment of their own edit mode. The banner never competes with them, which is the reason it stays on warning instead of being promoted to the accent — a permanently-accented banner would permanently occupy the budget the save actions need.
  • Status-vs-accent rule holds in the other direction too: warning here is used as a category hue for "puntos", not to signal a warning state. That reuse is inherited (§17.1) and is the one place this spec knowingly bends the rule rather than breaking it — §20.1 is the clean exit.

19. Whole-card clickable (already decided — implementation requirement, not a redesign)

José: "make the whole card clickable, not just the text." The entire banner opens PointsInfoModal.

19.1 The mechanism — a real <button>, not a clickable <div>

The card root is a native <button type="button">; Radix's DialogTrigger asChild clones onto it. This is strictly better than mirroring TableRow's whole-row pattern (packages/ui/src/components/table.tsx): that component hand-rolls tabIndex, Enter/Space, and the isNestedInteractive guard only because a <tr> cannot be a <button>. Here it can, so focusability, Enter/Space activation, aria-haspopup="dialog", aria-expanded and data-state all come from the platform + Radix for free — zero hand-rolled a11y.

The load-bearing condition that makes that legal: the card must contain zero other interactive descendants. A <button> inside a <button> is invalid HTML and is exactly the case isNestedInteractive exists to paper over. Therefore:

  • The PointsInfoModal trigger is removed from inside the banner. Its text becomes the static hint in §17.2 (type-small text-fg-muted + arrow glyph) — the affordance label for the card itself, not a second control. The action?: React.ReactNode prop added in §4.5 goes away with it.
  • PointsInfoModal keeps its own default TextLink trigger for its other consumer, apps/portal/src/components/cart-view.tsx — and §12.7's min-h-11 touch-target fix stays there. The banner path is additive (§19.3).
  • If a second action is ever added to the banner (e.g. "Usar puntos" → cart), the card must stop being the trigger, or adopt TableRow's nested-interactive guard verbatim. Flagged so the next person does not discover it by shipping a nested button.

Required on the button root (Frontend):

  • w-full text-left cursor-pointer — buttons centre their text and show the default cursor.
  • outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-offset-2.
  • Spread ...props onto the root and accept ref as a normal prop (F1, no forwardRef) — DialogTrigger asChild wires onClick / aria-* / data-state through them.
  • Accessible name = the card's own text ("Tus puntos, 1,240 puntos, Equivalen a …, Acerca de los puntos"). Do not add an aria-label — it would override that with something less useful.
  • Touch target: the whole card, ≫44px at every width. §12.7's flagged ~22px trigger disappears from this page as a side effect.

19.2 Composition — two explicit variants, no boolean prop

The card is a <button> when there is a dialog to open and a <div> when there is not (earnRate <= 0 = the tenant's points program has no published rate). That is an element-semantics fork, so it is two explicit variant components over one shared internal body (E2/E3), never an interactive?: boolean prop (E1/AP6):

// account-points-banner.tsx
function AccountPointsBannerBody({ balance, redeemValue }:) // internal, not exported
export function AccountPointsBanner(props: BannerProps & ComponentProps<'div'>) // static
export function AccountPointsBannerButton(props: BannerProps & ComponentProps<'button'>) // trigger
// BannerProps = { balance: number; redeemValue?: number; hint?: React.ReactNode }

PointsInfoModal gains an optional children trigger slot (E4, children-over-render-props), defaulting to today's TextLink button so cart-view.tsx is untouched:

{
balance > 0 ? (
earnRate > 0 ? (
<PointsInfoModal earnRate={earnRate} pointValue={pointValue}>
<AccountPointsBannerButton
balance={balance}
redeemValue={balance * pointValue}
hint="Acerca de los puntos"
/>
</PointsInfoModal>
) : (
<AccountPointsBanner balance={balance} />
)
) : null
}

The balance <= 0 guard moves from the component to the page. A Radix Slot requires exactly one child element; a trigger child that returns null throws. The early return inside the component is therefore replaced by the page-level ternary above — noted because it is a real behavioural gotcha, not a style preference.

19.3 Files Frontend touches

FileChange
apps/portal/src/components/account-points-banner.tsxFull rewrite per §17 + §19.2: shared body, two variants, action prop removed, redeemValue/hint added, data-slots
apps/portal/src/components/points-info-modal.tsxOptional children trigger slot; default trigger unchanged (cart consumer)
apps/portal/src/app/(portal)/mi-cuenta/perfil/page.tsxThe composition above; balance > 0 guard hoisted here
apps/portal/src/app/(portal)/mi-cuenta/perfil/loading.tsxBanner skeleton h-28h-40
apps/portal/src/app/portal-theme.cssThe two token-layer blocks (§17.3 wash, §17.4 glint + guard)

20. Open — José's call, not the Designer's

  1. Coral instead of amber, and only if both banners move together. .client.editorial ships an unused MD3 "tertiary" trio — --contrast / --contrast-container (14% coral into white) / --contrast-on-container (#7a2416, measured ≈8.37:1 on its container) — explicitly documented as the counter-accent that is not the accent and not a status hue. It is the semantically correct family for a reward, and it would stop --color-warning doing double duty as a category colour (§18, last bullet). It is not spec'd because (a) the amber was José's 2026-09-04 call, and (b) CoursePointsBanner would have to move with it or the two "puntos" surfaces diverge. If wanted: it is a token swap in §17.2/§17.3 (warning-softcontrast-container, text-warning--contrast-on-container, --tone-amber-* → coral color-mixes) across both files, no structural change.
  2. The zero-balance state. balance <= 0 still renders nothing, so a student who has never bought anything never learns the points program exists — a missed onboarding moment on the one page where the program is explained. A calm variant ("Aún no tienes puntos — ganas X% en cada compra") is a small, self-contained addition, but it is a product call (does the program exist for every tenant? should a zero state advertise it?), not a visual one. Not spec'd.
  3. Figure at type-display (52px). §4.5 shipped type-hero; this promotes it a role. It is the deliberate "make it loud" move (16.3) and the largest single change to the page's silhouette. One-word revert: keep type-hero.
  4. The glint. One 900ms light sweep across the medal, once per page load (§17.4). It is the only purely decorative element in the spec. If it reads as gimmick rather than gloss on a real screen, delete the ::after block — nothing else depends on it. [EYES]

21. Ratified 2026-09-06 (José) — §20 closed, plus the zero-balance state

The four §20 items are decided. §16–§20 stand as written except where a decision below explicitly supersedes a line; the changes are enumerated so Frontend implements from §21 where they conflict.

21.1 Decisions 1–3 (no new design work)

§20DecisionEffect on the spec
1Amber stays. No swap to the --contrast coral trio.None — §17.1 was already the spec'd default. The coral option is closed, not deferred; --color-warning remains the "puntos" category hue across both banners.
3The glint ships as spec'd (§17.4).None. (Scoped in §21.5 so it fires only on the earned state.)
2The balance steps DOWN to type-hero (36px foundation / 44px in .client.editorial), not type-display.Supersedes §17.2 change 2 and the §16.3 diagnosis. Rationale accepted: at 52px the figure outsized the page's own type-section title (30px) by 1.73× and made the banner read as the page's subject rather than one of its cards. At 44px the step is 1.47× over the title — still unambiguously the loudest thing on the card, no longer louder than the page.

Consequences of decision 2, in full (nothing else in §17 changes):

  • §17.2 figure row: type-hero text-fg-primary + unit type-body text-fg-muted, flex items-baseline gap-2. This is the shipped role — the change vs. today is the anatomy around it (medal, wash, worth line, hint), not the type role.
  • §17.6 loading height re-derives: label 16 + gap-1 4 + figure ≈48 (44 × 1.1 editorial type-hero leading) + 4 + worth ≈23 = 95, + 48 padding = ≈143px. h-36 (144px) is the exact match — h-28h-36, superseding §17.6's h-40. [EYES]
  • §20.3 is closed. No responsive type step is needed or possible (type-* are @layer components classes — §17.2 note stands).

21.2 The zero-balance state — decision

balance <= 0 no longer renders nothing. It renders the same reward card in an un-earned state: same frame, same medal, same interaction language, drained of the reward colour. The motivating idea is the contrast itself — the card colours in when you earn. Amber is the reward; withholding it is the prompt.

This supersedes §17.5's empty row and the balance > 0 ? … : null half of §19.2's ternary.

What earns points, verified in code (packages/modules/src/points/accrual.ts): a confirmed payment, at floor(amount × earnRatePerCurrencyUnit). There is exactly one accrual path — no enrolment bonus, no referral, no profile-completion award. So the copy can name the real mechanism and the real rate instead of a vague nudge.

21.3 The empty card — visual spec

┌──────────────────────────────────────────────────────────────────────────┐
│ ╭────╮ TUS PUNTOS │
│ │ ✦ │ Gana tus primeros puntos Ver cursos → │
│ ╰────╯ Ganas el 10% en puntos con cada compra │
│ confirmada y los usas como descuento en la siguiente. │
└──────────────────────────────────────────────────────────────────────────┘
PartEarned state (§17.2)Empty state
root framerounded-xl border p-4 sm:p-6, same layoutidentical — same radius, padding, flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6
root borderborder-(--tone-amber-edge)border-border-default
root backgroundbg-surface + the amber radial wash (§17.3)bg-surface, no wash — the radial rule is scoped off it (§21.5)
medalbg-warning-soft text-warning ring-8 ring-(--tone-amber-soft)bg-sunken text-fg-muted ring-8 ring-border-subtle — same size-14 rounded-pill, same 28px SparklesIcon
medal glintfires once at +700msnone — nothing to celebrate yet (§21.5)
eyebrowtype-label text-warning "Tus puntos"type-label text-fg-muted "Tus puntos" — same string, so it reads as the same object
figure slottype-hero balance + type-body unittype-h1 text-fg-primary headline: "Gana tus primeros puntos"
worth linetype-small text-fg-secondary "Equivalen a $X …"replaced by the earn line (below) — see §21.4
earn linetype-small max-w-prose text-fg-secondary: "Ganas el {N}% en puntos con cada compra confirmada y los usas como descuento en la siguiente." {N} = Math.round(earnRate * 100), same derivation PointsInfoModal already uses
hint"Acerca de los puntos" + arrow"Ver cursos" + the same ArrowRight01Icon — see §21.4

Why type-h1 (22px in .editorial) and not the figure's type-hero: the earned card's 44px is sized for a number (4–6 tabular-ish glyphs). "Gana tus primeros puntos" at 44px wraps to three lines inside a 296px phone card and reads as shouting. type-h1 keeps it a headline, one or two lines at every width, and the silhouette difference between the two states is correct — an empty state that clones the filled state's mass in grey is worse than one that admits it is different.

Everything else — entrance motion, hover/press behaviour, focus ring, <sm> stack — is identical to the earned card (§17.4/§17.5), except the hover wash is neutral: hover:bg-sunken instead of hover:bg-warning-soft, because amber is reserved for the earned state.

21.4 The two questions asked, answered

Does the empty state show the worth line? No. The worth line converts a balance into pesos; with a balance of zero it would render "Equivalen a $0 en tu próxima compra." — a true statement that reads as a rebuke on the one surface whose job is to motivate. It is replaced, not omitted: the earn line states the same relationship forward (what a purchase will produce) instead of backward (what nothing is worth). Principle: Purpose — the line exists to make points feel valuable, and at zero that value lives in the rate, not the balance.

Does the whole card still open the modal? No — it links to /tienda. The card stays ONE interactive element (identical interaction language to the earned state), but the destination changes:

StateRoot elementDestination
earned<button> via DialogTrigger asChildPointsInfoModal
empty<a> (next/link)/tienda — where a purchase begins
no programme<div> (§19.2, unchanged)none

Reasoning:

  1. The dialog would be redundant. Its two facts are the earn rate and the redemption value — the earn line already states the first concretely, and the second is meaningless at a zero balance. Opening a modal to re-read the sentence just above the fold is a dead end.
  2. The useful next step is the action that earns points, and there is exactly one (a confirmed payment, §21.2). Sending the student to the store is the only affordance that changes the state the card is describing.
  3. It costs nothing structurally. Still one control, still no nested interactive descendant, so §19.1's "the card must contain zero other interactive descendants" condition holds unchanged — and an <a> gets focus, Enter activation, middle-click and prefetch from the platform, exactly as the <button> gets its behaviour from Radix.

Trade-off, named: the empty card no longer offers the T&C link that lives inside PointsInfoModal. Accepted — §4.4 already established that TenantFooter carries Términos on every /mi-cuenta/* page, and duplicating legal links into a card was the exact pattern §4.4 deleted.

Rejected alternative: an inline Button CTA ("Ver cursos") inside a static card. It reintroduces a nested control, breaks the one-language-per-state symmetry, and a default-variant Button resolves to bg-primary — spending the page's accent moment on an empty state (§18). If José prefers a visible button over a whole-card link, the button must be variant="outline" and the card must then be a plain <div>.

21.5 Component + page shape (supersedes §19.2's ternary)

Three explicit variants over one shared frame — an element-semantics fork, so never a boolean prop (E1/AP6). The frame takes a tone cva variant (internal, the same pattern Card/EmptyState already use), not an exported boolean:

// account-points-banner.tsx
function AccountPointsBannerFrame({ tone, ...props }) // internal: chrome + tone (earned | empty)
function AccountPointsBannerEarned({ balance, redeemValue }) // internal body
function AccountPointsBannerEmpty({ earnRate }) // internal body

export function AccountPointsBanner(props) // <div> — earned, no dialog available
export function AccountPointsBannerButton(props) // <button> — earned, DialogTrigger asChild
export function AccountPointsBannerLink(props) // <a> — empty, href to the store
// AccountPointsBannerLink({ earnRate, href, hint }) renders `next/link` (app-local component,
// so importing next/link is fine — the Next-free rule governs @tedos/ui, not apps/portal)

Page composition (perfil/page.tsx), replacing §19.2's:

{
balance > 0 ? (
earnRate > 0 ? (
<PointsInfoModal earnRate={earnRate} pointValue={pointValue}>
<AccountPointsBannerButton
balance={balance}
redeemValue={balance * pointValue}
hint="Acerca de los puntos"
/>
</PointsInfoModal>
) : (
<AccountPointsBanner balance={balance} />
)
) : earnRate > 0 ? (
<AccountPointsBannerLink earnRate={earnRate} href="/tienda" hint="Ver cursos" />
) : null
}

Full render matrix — every row is a real distinction, not a fork of convenience:

balanceearnRateRendersRootInteraction
> 0> 0earned card<button>opens the dialog
> 0≤ 0earned card (§19.2, unchanged)<div>none
≤ 0> 0empty card<a>navigates /tienda
≤ 0≤ 0nothing — no programme, nothing to say

The last row is the one case where silence is right: with no published earn rate there is no concrete promise to make, and a card that says "earn points somehow" is the vague nudge §21.2 exists to avoid. The balance <= 0 early return stays out of the component (§19.2's Slot gotcha is unchanged) — the page owns the whole matrix.

Token-layer scoping (portal-theme.css, both §17.3 and §17.4 blocks): the empty root and medal carry data-tone="empty", and both rules gain :not([data-tone='empty']) so the amber wash and the glint apply to the earned state only. One selector change per rule, nothing else:

.client.editorial [data-slot='account-points-banner']:not([data-tone='empty']) {
/* …the §17.3 radial wash, unchanged… */
}
.client.editorial [data-slot='account-points-medal']:not([data-tone='empty'])::after {
/* …the §17.4 glint, unchanged… */
}

21.6 Updated state matrix (the rows §17.5 changes)

StateTreatment
empty (balance ≤ 0)The un-earned card (§21.3): neutral frame + bg-sunken/fg-muted medal, type-h1 headline, concrete earn line, whole card links to /tienda. No wash, no glint, no worth line
empty hover (pointer)root hover:bg-sunken; medal scale-105; arrow translate-x-1 — same 140ms --ease-out
empty focus-visible / pressidentical to the earned card (ring-3 ring-ring/50 ring-offset-2; medal group-active:scale-95)
empty entranceidentical (animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out) — the card arriving is worth the same 300ms either way
no programme (earnRate ≤ 0 and balance ≤ 0)renders nothing
loadingh-36 (§21.1). The empty card measures ≈16+4+29+4+2×23 = ≈120px at one wrap, so the skeleton over-reserves ~24px in that branch — accepted: loading.tsx cannot know the balance, and one skeleton that is slightly tall beats two that can drift. [EYES]

21.7 Accent budget — unchanged, still zero

The empty card adds no accent surface: bg-sunken, border-border-default, border-border-subtle, fg-muted/fg-primary/fg-secondary, and the same navy --ring focus. §18's table stands as written — rest-state total 0 in every row of the §21.5 matrix. The rejected inline-Button alternative (§21.4) is the one shape that would have broken it.

21.8 Files Frontend touches — delta on §19.3

FileAdditional change from §21
apps/portal/src/components/account-points-banner.tsxThird variant AccountPointsBannerLink + the internal tone frame + the empty body; figure role is type-hero, not type-display
apps/portal/src/app/(portal)/mi-cuenta/perfil/page.tsxThe §21.5 four-row matrix (replaces §19.2's two-row ternary)
apps/portal/src/app/(portal)/mi-cuenta/perfil/loading.tsxBanner skeleton h-28h-36 (was h-40 in §17.6)
apps/portal/src/app/portal-theme.cssBoth blocks gain :not([data-tone='empty']) (§21.5)
apps/portal/src/components/points-info-modal.tsxUnchanged from §19.3 (optional children trigger slot; cart consumer untouched)

Nothing else in §16–§20 changes. This closes the spec — no open Designer questions remain.


22. Header action cluster (2026-09-06) — hover contrast, spacing rhythm, sign-out weight

Design Spec for José's live review of the signed-in header cluster on the Comprender tenant (issue #1631, PR #1633). Scope: tenant-header.tsx's right-hand action cluster and the two components inside it (cart-nav-button.tsx, user-menu.tsx), plus the ONE foundation token change they expose. This is the "separate pass" §12.8 flagged. Numbers below are computed from the token layer (WCAG relative-luminance formula), not eyeballed.

22.1 The finding — ghost's hover is invisible, and it is foundation-wide

Button's ghost variant hovers to bg-muted (packages/ui/src/components/button.tsx). --muted is aliased to --color-bg-elevated in both token layers — this is not a portal override, it is the shared default:

Layer--color-bg-canvas--color-bg-elevated (= --muted)Hover contrast
packages/ui/src/tokens/theme.css (78/80/229)#f7f7f5#f0f0ee1.06:1
apps/portal/src/app/portal-theme.css (125/127/261)#f7fbfd#eaf6fc1.06:1

1.06:1 is below the threshold at which a background change is reliably perceived on a light field. José is not misreading the screen — the state is effectively not rendering. It is worse in the header than anywhere else because HeaderScrollShadow is bg-transparent at rest, so the cart button hovers directly against --color-bg-canvas, and because an icon-only button has no label, no border and no underline to carry the affordance on its own.

This is not icon-only-specific and not portal-specific: every ghost Button in every app has the same 1.06:1 hover. Icon-only just removes the last fallback cue. Fix it at the variant.

22.2 Decision — ghost hover becomes a state layer, not a ramp step

ghost hover: bg-muted → bg-foreground/10
ghost active: (none) → bg-foreground/15
hover:text-foreground and dark:hover:bg-muted/50 unchanged
aria-expanded:bg-muted unchanged (an open popover is a different state, it may stay flat)
CandidatePortalFoundationOn a bg-sunken hostVerdict
bg-muted (today)1.06:11.06:11.09:1invisible
bg-sunken (one step down)1.15:11.14:11.00:1better, but dies on sunken hosts
bg-foreground/101.20:11.23:1~1.2:1adopted — surface-independent

Why the state layer over bg-sunken, even though bg-sunken is already the repo's de-facto hand- rolled hover (account-sidebar.tsx:173, operator-page-band.tsx:95/184, operator back-link.tsx:28, the DataTable row hover): a fixed ramp step assumes the host surface is canvas. It is not always — student-detail-view.tsx:244/279 puts ghost size="xs" buttons inside a bg-sunken card, where hover:bg-sunken would render a 1.00:1 no-op. A translucent foreground layer darkens whatever is behind it, so one rule holds on canvas, on bg-surface cards, on the scrolled header's bg-surface/90 blur, and on sunken panels. It is also the Material 3 state-layer model the portal's own token comments (portal-theme.css:110-121) are already mapped to; 10% is one step above MD3's 8% hover, chosen because both neutral ramps here are unusually compressed.

bg-foreground/10 is a token with an alpha modifier, the same shape as the shipped bg-destructive/10, bg-primary/15, ring-ring/50 — not an arbitrary value.

Blast radius: every ghost Button repo-wide (28+ files). Every one of them gets a stronger hover than today; there is no surface where 1.06:1 was load-bearing. Reviewed the two riskiest classes — ghost-on-ink (announcement-bar-client.tsx:67, which already overrides colors at the call site with text-(--color-ink-fg) and is unaffected by the background rule) and ghost-on-sunken (student-detail-view.tsx, which improves from 1.09 to ~1.2). No regression found.

Out of scope, flagged: outline hovers to the same bg-muted and has the same 1.06:1 problem. It is less urgent (a border already carries the affordance) and is a wider blast radius. Separate pass — do not fold it into this PR.

Motion: unchanged. The Button base already transitions; the hover reads at --duration-hover (140ms) --ease-out. Do not add a duration to the variant.

22.3 The spacing rhythm — 16 / 8, no divider

Today the three controls sit at 12px then 8px (tenant-header.tsx cluster gap-3, user-menu.tsx wrapper gap-2). Two problems: 12px is a half-step off base-8 (G2), and a 1.5:1 ratio is too close to read as a grouping — the eye sees three near-equidistant items when the cluster actually contains two functions: commerce (cart) and account (Mi Cuenta + sign-out).

ElementFromToWhy
tenant-header.tsx action-cluster wrappergap-3gap-416px, on base-8; separates the two functions
user-menu.tsx wrappergap-2gap-2unchanged — 8px binds the account pair into one object

16 / 8 is a clean 2:1. Proximity alone then encodes the grouping, which is why there is no divider and no extra separator track — a hairline here would be ornament doing a job whitespace already does (G15: separation is never filler). The signed-out branch inherits the same 16px between "Iniciar sesión" and "Inscríbete", which is correct: those are two peers, not a bound pair. MobileNav at the far right also lands on 16px — a third group, correctly spaced.

All three controls stay on the same size step (size="lg" and size="icon-lg" are both 36px tall), so the row's vertical rhythm is already correct — do not change sizes here. [EYES] §12.8's sub-44px touch-target note still stands for phones and is still a separate pass.

22.4 The sign-out button — spotlight is the wrong weight, and this reverses today's call

Honest read: variant="spotlight" (solid bg-danger + text-fg-onAccent) is wrong for this slot, and the rendered result proves it. José's instruction ("red background and contrast color for text or icons") was a correct description of a spotlight CTA; the mistake was applying it to a persistent piece of chrome. Three concrete failures, principle by principle:

PrincipleFailure
ResponsibilitySolid danger is the system's strongest promise: irreversible consequence. Signing out is neither destructive nor irreversible. Spending the loudest signal on a routine action inflates it — when a genuinely destructive confirm later needs solid red, the user has been trained to ignore it.
PurposeThe header is chrome; the page is content. A solid red disc is the single highest-contrast object on every route in the app, including checkout, where the page's own primary CTA must win. Chrome now outranks content everywhere, permanently.
CraftHue collision: --color-danger is #c0322b (foundation, not overridden by the portal) and the cart-count badge's --contrast is #c83b24 (portal-theme.css:200). Two near-identical reds inside a ~200px cluster — the badge stops reading as a count and starts reading as decoration.

It also inverts the cluster's weight gradient: visual emphasis climbs left-to-right and peaks on the exit, while the commerce action that carries revenue is the quietest thing in the row.

Decision — sign-out becomes ghost at rest, danger on intent:

variant="ghost" size="icon-lg"
rest text-fg-muted (no fill, no border, no red)
hover bg-danger/10 text-danger (overrides §22.2's neutral state layer — deliberate)
active bg-danger/20 text-danger
focus-visible the shared base ring (focus-visible:border-ring ring-3 ring-ring/50) — unchanged
disabled n/a (never disabled)

That is the exact token pair the shipped destructive variant already uses (bg-destructive/10 text-destructive hover:bg-destructive/20) — no new token, no new value. Red still appears, and it appears at the moment it means something: when the pointer is on the control. This honors the substance of José's instruction (red + contrast, on the sign-out) while removing the permanence that broke it.

Add a Tooltip ("Cerrar sesión") from @tedos/ui on the trigger, since ghost + icon-only leaves the label carried only by aria-label. Keep the aria-label.

Rejected alternatives: variant="destructive" at rest (still a permanent red disc, only quieter — same Purpose failure at lower amplitude). Moving sign-out out of the header entirely and into the /mi-cuenta sidebar is the structurally cleanest answer and would leave a two-item cluster, but it re-opens the dropdown decision José closed on 2026-09-05 — noted as an option, not recommended against his call.

22.5 Accent budget after the change

SurfaceResolves toCounts?
Cart icon (ghost, rest)fg-secondaryno
Cart count badge (when items exist)--contrast coralthe header's one attention moment, conditional
"Mi Cuenta" (ghost, or secondary when active)bg-elevatedno
Sign-out (ghost, rest)fg-mutedno
Sign-out hoverdanger/10no — transient, and danger is a status tone, never the accent (G17)

Rest-state accent total: 0 when the cart is empty, 1 when it is not. Correct. Today's version scores 2 permanently.

22.6 Files Frontend touches

FileChange
packages/ui/src/components/button.tsxghost variant: hover:bg-mutedhover:bg-foreground/10, add active:bg-foreground/15 (§22.2). Nothing else in the cva changes
apps/portal/src/components/tenant-chrome/tenant-header.tsxaction-cluster wrapper gap-3gap-4 (§22.3)
apps/portal/src/components/user-menu.tsxsign-out variant="spotlight"variant="ghost" + the §22.4 className state pair; wrap in Tooltip; wrapper gap-2 unchanged
.claude/rules/foundation-ui-conventions.mdone line under the Button amendments recording the ghost hover state-layer rule

cart-nav-button.tsx needs no change — it inherits the fixed hover from the variant.


23. The mobile menu (2026-09-06) — the < md hamburger panel, designed from scratch

Design Spec for the header's mobile navigation panel (issue #1631). José's instruction was explicit: do not polish the rough version — design it. So this section treats apps/portal/src/components/tenant-chrome/mobile-nav.tsx as disposable scaffolding and re-derives the panel from the requirements. Everything below is computed from the JSX + the token layer (same method as §0/§9); items needing eyes-on are marked [EYES].

Requirements taken as fixed (José): a close button must exist · the 3 site links always · the 5 "Mi cuenta" links only when signed in, visually distinguishable as their own group · tokens only · transform/opacity motion (G6/C3) · focus trap + Escape + DrawerTitle (Radix) · HugeIcons only.

23.1 Diagnosis — three real problems, only one of them cosmetic

#FindingWhy it matters
1Two hidden menus in one 200px cluster. At < md the header renders three icon controls — cart, UserMenu (opens a DropdownMenu: "Mi Cuenta" + "Cerrar sesión"), MobileNav (opens the Drawer: 3 site links + 5 account links). Both disclosures are account surfaces, and both contain a route to /mi-cuenta/perfil ("Mi Cuenta" in the dropdown, "Mi Perfil" in the drawer).Two overlapping menus 8px apart is the exact failure §10.1 rejected the hamburger for in the first place ("two hidden menus stacked on one screen is worse than one visible rail"). Familiarity + Simplicity. This is the headline.
2The floating-card panel is a desktop idiom miniaturized. inset-y-2 right-2 rounded-xl border leaves an 8px sliver of dimmed overlay on three sides. At 360px that spends 16px of a 300px panel and stacks three boundaries (overlay dim + full border + radius) around content that needs the width. An 8px gap does not read as "floating"; it reads as a panel that failed to reach the edge.Craft. A floating rounded card is right for a drawer over a wide desktop page; it is wrong for a full-bleed phone menu.
3No close affordance, and 500ms to open. No X (José's ask). The primitive's enter is duration-500 — for chrome a student opens several times a session, that is 2.5× the ceiling for a high-frequency UI animation.Agency (no visible way out) + Craft (the panel feels slow, which reads as the app being slow).

Not problems: the Drawer primitive itself (Radix owns focus trap / Escape / outside-click / body-scroll lock — correct choice, keep it), the right edge (see 23.2), and the min-h-11 touch floor already used on the rows.

23.2 Decisions — edge, shape, width

Right edge, full height, flush. Weighed:

OptionVerdict
Right, full-height, flushThe trigger is the header's top-right control, so the panel arrives from the edge the thumb is already on and leaves the same way — spatial consistency, and the close X can land on the trigger's own coordinates (23.3). Flush means the panel keeps the width.
Right, floating card (today)Reject — finding 2 above.
Bottom sheetReject. Thumb-reachable, but 9 rows make it near-full-height anyway (it stops being a sheet), it lands on iOS Safari's own bottom chrome + keyboard accessory bar, and a bottom sheet reads as contextual action, not site navigation.
Top (menu drops out of the header)Reject, though it is the most spatially honest. It covers the trigger AND the logo, so the panel has to rebuild the whole masthead inside itself to stay oriented; and side="top" + h-auto gives a panel whose height jumps when the account group appears/disappears.

Radius on the leading edge only — rounded-l-xl (R1, 14px), no other radius, no added border. The panel's right/top/bottom edges coincide with the viewport, so they are edges the eye never sees; the only visible boundary is the left one, so it is the only one that earns a corner. This is the same rule already ratified for the operator shell's floating content (#1283 alignment critique #3: "radius tracks the gap — flush edge square, floating edge R1"), applied here rather than invented. The primitive's side="right" class already ships border-l — take it, and do not add border (a full border on a flush panel draws three lines nobody can see).

Width w-10/12 max-w-xs (fractional + named utilities, no arbitrary value):

ViewportPanelPage strip left visiblePanel content box (p-4)
36030060268
390320 (capped)70288
430320 (capped)110288
767320 (capped)447288

The visible strip is load-bearing: it is what makes the panel read as an overlay over a page rather than as a new page, so "tap the page to go back" is legible without instruction. Capping at 320 (max-w-xs) keeps the panel one stable object across every phone and tablet-portrait width instead of growing into a half-screen slab at 767.

Panel padding p-4 (16px), replacing the primitive's p-6. 24px per side on a 300px panel is 16% of the width spent on air. 16px is the mobile gutter this spec already standardised in §11 — the panel's inset now matches the page's.

23.3 The close button lands exactly where the hamburger was

The panel is flush right, so it covers its own trigger. Put the X on the trigger's coordinates and the panel reads as the hamburger turning into an X — one thumb position toggles both directions, and no open-state styling is needed on the trigger (it is behind the panel).

The trigger is vertically centred in the header's h-20 rail → its centre is 40px from the viewport top. So:

DrawerContent p-4 → panel padding-top 16
DrawerHeader h-12 (48px) → the header row spans y = 16…64
close button centred in that row → centre y = 16 + 24 = 40 ✓ exactly the trigger's centre
  • Close control: Button variant="ghost" size="icon-lg" className="size-11" + HugeIcons Cancel01Icon (strokeWidth={2}, matching the header's other icons), wrapped in DrawerClose asChild. size-11 (44px) overrides icon-lg's size-9 through twMerge while the variant's [&_svg]:size-5 glyph rule survives — so a 44px hit area with a 20px glyph, honouring the portal's 44px touch floor without inventing a value. aria-label="Cerrar menú".
  • The row carries nothing else. No visible "Menú" title: the panel is the menu, the rows say so, and a 300px panel spends vertical rhythm better on destinations. No tenant wordmark either — it would put a second "COMPRENDER" on screen. DrawerTitle stays sr-only ("Menú").
  • Drop DrawerDescription. Radix only requires a Title; the current sr-only sentence is a paragraph a screen-reader user must hear on every open. Pass aria-describedby={undefined} on DrawerContent if Radix dev-warns.
  • The panel's first nav row therefore starts at 80px (16 pt + 48 header + 16 gap-4) — the exact bottom edge of the page header behind it. The panel's content line continues the page's.

Trigger hit area, without breaking the cluster's rhythm. §22.3 fixed the three header controls on one 36px size step and said not to change sizes — correct, so the hamburger keeps variant="ghost" size="icon-lg" visually and gains only an invisible target extension: relative after:absolute after:-inset-1 after:content-[''] → 36 + 8 = 44px hit area, zero visual change, no row-rhythm break. (This is the cheap general answer to §12.8; applying it to cart and profile is still that separate pass.)

23.4 Anatomy — two groups, no card, no rule

The account group stops being a Card. A bordered card inside an already-elevated panel is a card-in-card (an absolute anti-pattern), and at 300px it charges 2×16px of width plus a second boundary for a job whitespace and a label already do. Nor does the split get a hairline: a label plus a rule plus 24px of air is three separators doing one separator's work (G15 — separation is never filler).

DrawerContent side="right" p-4 w-10/12 max-w-xs rounded-l-xl (flex-col gap-4 from the primitive)
├─ DrawerHeader h-12 flex items-center justify-end
│ └─ DrawerClose → Button ghost size-11 · Cancel01Icon (sr-only DrawerTitle "Menú")
├─ DrawerBody -mx-2 px-2 (replaces the primitive's -mx-1 px-1 — 8px of bleed room for the row pill)
│ └─ div flex flex-col gap-6
│ ├─ nav aria-label="Navegación" flex flex-col gap-1 ← 3 site links, type-body, NO icons
│ └─ nav aria-label="Mi cuenta" flex flex-col gap-1 ← only when `account`
│ ├─ SectionLabel mb-1 "MI CUENTA"
│ └─ 5 rows, type-small, HugeIcons leading (size inherited, not a per-icon class)
└─ DrawerFooter (23.6 — session/CTA actions; omitted entirely when it has nothing to hold)

Alignment maths (why -mx-2 px-2 twice). Panel inset 16 → DrawerBody -mx-2 px-2 keeps the text box at 16 while giving the row 8px of overflow room the scroller won't clip → each row -mx-2 px-2 rounded-md puts its hover/active pill at 8px from the panel edge and its label at 16px. Label edge = panel inset = page gutter (§11). This is SecondaryNav's ratified inset model (#1283 critique #2/#5: one content inset down the column, the row pill alone bleeds past it), reused verbatim.

The icon asymmetry is meaningful, not sloppy. Site nav is typographic (mirrors TenantNav at md+, which is deliberately icon-free); the account group is iconographic (mirrors AccountSidebar). The same distinction the desktop makes, carried to the phone — and it is what lets the two groups read as different kinds of navigation without a box around one of them. Do not invent icons for Tienda / Nosotros / Contacto.

Type + rhythm:

ElementClassHeight
Site nav rowtype-body (15/400) + font-medium when activemin-h-11 (44)
Group labelSectionLabel (type-label, mono UPPER, fg-secondaryfg-muted fails AA below 24px, #492)
Account rowtype-small (14/400) + font-medium when active, gap-3 icon→labelmin-h-11 (44)
Between groupsgap-6 (24)
Within a groupgap-1 (4)

Every row is 44px: one consistent tap rhythm. Hierarchy is carried by type size, icons and the label, never by making a tap target smaller. Panel content height signed in ≈ 16+48+16+132+24+20+4+220 = 480px before the footer, so it fits a 640pt viewport without scrolling; DrawerBody still scrolls for the short ones ([EYES] on a 568pt iPhone SE).

23.5 State matrix

ElementStateTreatment
Trigger (hamburger)restghost, text-fg-secondary; 36px box / 44px hit area
hoverbg-foreground/10 — inherited from §22.2's variant fix, nothing at the call site
active (press)bg-foreground/15 (§22.2)
focus-visiblethe Button base ring (focus-visible:border-ring ring-3 ring-ring/50)
panel openno styling — the panel covers it; the X is its open state
Panelclosednot mounted (Radix)
openbg-surface, border-l border-border-default, rounded-l-xl, shadow-md, z-(--z-modal), overlay bg-ink/50 — all from the primitive
Nav rowresttext-fg-secondary
hoverbg-foreground/10 text-fg-primary — ≈1.2:1 on the white panel, the magnitude §22.2 ratified. Tailwind v4 already scopes hover: to (hover: hover), so do not hand-wrap a media query
active (press)bg-foreground/15 (same pair as every ghost control since §22.2)
current routebg-sunken text-fg-primary font-medium + aria-current="page". bg-sunken on bg-surface = 1.20:1 — same fill weight as hover, so the state is distinguished by three further cues (text colour, font weight, aria-current), not by fill alone
focus-visiblefocus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none (as today)
disabledn/a — a route is never disabled
Account groupsigned outthe whole <nav> is absent (not hidden, not empty-stated) — it is not a state of this panel, it is a different panel
Close buttonrest / hover / active / focusghost at 44px, inheriting §22.2's state layers + the base ring
Panel, 4 states (H1)Nav is static route data from TENANT_NAV_LINKS / ACCOUNT_NAV_ITEMS — no fetch, so loading/empty/error are unreachable by construction. Record it; do not build skeletons for it

23.6 Motion — fast, no stagger, override at the call site

PropertyValueWhy
EntertranslateX + fade, 200ms, --ease-outEmil's frequency rule: chrome a student opens several times a session gets no ceremony. 200ms is the top of the "dropdown/select" band and reads instant; the primitive's 500ms reads as lag. data-[state=open]:duration-200 at the call site
Exit150ms, --ease-outDismissal is a system response, not a decision — exit is always snappier than enter
Overlaythe primitive's fade-in-0 / fade-out-0, unchangedopacity only
Row pressbg-foreground/15, colours only, --duration-hover (140ms) from the Button/base transitionA full-width row that scales looks like a layout bug; the state layer is the feedback
Staggernone9 rows × 40ms = 360ms before the last row settles — longer than the panel itself, on a control used repeatedly. Restraint governs ornament; this would be ornament
Reduced motionnothing to addGlobally guarded in packages/ui/src/tokens/motion.css (see drawer.tsx's own note)
Drag-to-dismissnot this passRadix Dialog has no drag; adding it means a vaul dependency on @tedos/ui. Overlay tap + Escape + the X are three dismissals already. Flagged as a possible foundation upgrade, not a gap in this spec

Override the duration at the call site, not in drawer.tsx. The primitive's 500ms enter is also wrong for the operator screens' < md fork and the dashboard sidebar, but changing it there is a foundation change with blast radius across three consumers. Fix it here; flagged as worth a separate foundation pass (packages/ui/src/components/drawer.tsx: 500 → 200 / 300 → 150).

Signed out (spec, no other file changes): the footer carries "Inscríbete" as a full-width primary CTA (Button size="lg" shape="pill" className="w-full"/registro). This closes a real reachability hole: tenant-header.tsx:123 renders "Inscríbete" as hidden sm:inline-flex, so on any phone under 640px the registration CTA is currently unreachable anywhere in the chrome. A single primary CTA inside a modal panel is that panel's one accent moment (G17) — the accent budget is per surface, and this panel is its own surface.

Signed in (RECOMMENDATION — changes user-menu.tsx, so José's call): put "Cerrar sesión" in the footer and delete UserMenu's < md dropdown, making the header's profile icon a direct link to /mi-cuenta/perfil. That resolves finding 1: one disclosure on mobile instead of two, the duplicate route to perfil disappears, and José's own rule ("the cart AND the profile control are ALWAYS visible, never hidden behind a menu") is honoured more strictly than today, since the profile control stops being a menu trigger. Session exit is the conventional bottom-of-menu action on every mobile app, and DrawerFooter's pinned border-t border-border-subtle marks it correctly as a boundary, not filler (G15) — it is a different kind of action from navigation and it does not scroll with the list.

Treatment, if accepted — no new tokens, the exact pair §22.4 already ratified:

Button variant="ghost" className="w-full justify-start text-fg-muted hover:bg-danger/10 hover:text-danger"
Logout01Icon leading (size from the Button step, never a per-icon class)
DrawerFooter className="justify-start" // default is justify-end

If José declines: omit DrawerFooter entirely when signed in (no empty pinned row), and the UserMenu dropdown stays as-is. Everything else in §23 is unaffected — the recommendation is strictly additive.

iOS safe area on the footer. The panel is full-height, so a footer at p-4 puts its button's bottom 16px inside the 34px home-indicator inset. Use style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }} — the same escape already used at apps/portal/src/app/(store)/tienda/cursos/[offeringId]/page.tsx:175, and env() is not an arbitrary size value.

23.8 Accent budget

SurfaceResolves toCounts?
Every nav row (rest / hover / press)fg-secondary / foreground/10 / foreground/15no
Current-route rowbg-sunkenno — a neutral ramp step, not the accent
Group labelfg-secondaryno
Close buttonfg-secondaryno
"Cerrar sesión" hoverdanger/10no — a status tone, never the accent (G17), and transient
"Inscríbete" (signed out only)primary → portal coral1 — the panel's single accent moment

Signed in: 0. Signed out: 1. Correct either way.

23.9 Rubric — where principles pulled against each other

PrincipleCall
AgencyThe X exists, lands on the trigger's coordinates, and is 44px. Overlay tap + Escape kept.
SimplicityThe card, the border, the description sentence and the second hidden menu (recommended) all go. Nothing added that isn't a destination or a way out.
FamiliarityStandard right sheet, standard label-grouped list, session exit at the bottom. No novel gesture to learn.
CraftClose-at-40px, the leading-edge-only radius, 16px label inset shared with the page gutter, 44px on every row.
Purpose vs. Delight — the trade-off, namedThe panel has no entrance stagger and no bounce, which is the one place this spec spends nothing on delight. Deliberate: at this frequency, personality becomes latency. Playfulness in the portal belongs in empty states, success moments and the points banner (§17) — not in chrome the student passes through.
Familiarity vs. Agency — the trade-off, namedJosé's "profile is always one tap, never behind a menu" and "one mobile menu, not two" both pull on the profile icon. 23.7 resolves it by keeping the icon and removing its menu — but that is a recommendation, not a unilateral change to a component outside this spec's scope.

23.10 Files Frontend touches

FileChange
apps/portal/src/components/tenant-chrome/mobile-nav.tsxRewrite per §23.2–23.6: flush right panel (w-10/12 max-w-xs rounded-l-xl p-4, drop inset-y-2 right-2 h-auto border), h-12 header row with the DrawerClose 44px ghost X (Cancel01Icon), sr-only DrawerTitle + no DrawerDescription, DrawerBody -mx-2 px-2, two <nav>s in a gap-6 stack with SectionLabel on the second and no Card, row classes + state matrix per 23.4/23.5, duration-200/duration-150, DrawerFooter per 23.7. Trigger gains relative after:absolute after:-inset-1 after:content-['']
apps/portal/src/components/user-menu.tsxOnly if the 23.7 recommendation is accepted — delete the < md DropdownMenu, make the mobile profile control a direct Link to /mi-cuenta/perfil keeping its active variant + aria-current
apps/portal/src/components/tenant-chrome/tenant-header.tsxRecommended, one line — drop hidden sm:inline-flex on the "Inscríbete" Button so it is drawer-only below md and does not appear twice in the 640–767 band

No @tedos/ui changes. The flagged foundation follow-ups (drawer.tsx default durations; the 36px header controls of §12.8) stay out of this PR.