`/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:
| Viewport | inner | after sidebar+gap-8 | × 0.8 (the 8-of-10 inset) |
|---|---|---|---|
| 1280 | 1067 | 811 | 649 |
| 1440 | 1200 | 944 | 755 |
| 1536 | 1280 | 1024 | 819 |
| 1920 | 1600 | 1344 | 1075 |
This single fact explains almost every symptom in the review.
1. Verdict on the perfil layout (Task 1)
| # | Shipped | Verdict | Fix |
|---|---|---|---|
| 1.1 | Content column = md:grid md:grid-cols-10 + md:col-span-8 md:col-start-2 | Wrong. 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.2 | PageHeader wrapped in a nested grid gap-6 md:grid-cols-8 at md:col-span-4 | Wrong, 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.4 | 48px (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.5 | Sidebar 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.6 | Sidebar md:sticky md:top-24 | Right, 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.7 | Sidebar↔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.8 | Card 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.9 | Perfil 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.10 | Helper paragraphs in PhoneChange run the full card width | At 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.11 | PhoneChange's no-phone branch = EmptyState tone="surface" inside AccountSectionCard | Nested 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):
| Viewport | 768 | 1024 | 1280 | 1440 | 1536 | ≥1920 |
|---|---|---|---|---|---|---|
| track | 368 | 581 | 795 | 928 | 1008 | 1328 |
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:
| Viewport | cols | card width | |
|---|---|---|---|
| 768 | 2 | 180px | ✗ |
| 1024 | 3 | 183px | ✗ |
| 1280 | 4 | 185px | ✗ |
| 1440 | 4 | 218px | marginal |
| 1536 | 5 | 186px | ✗ |
| 1920 | 5 | 250px | ok |
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:
| Viewport | 768 | 1024 | 1280 | 1440 | 1536 | ≥1920 |
|---|---|---|---|---|---|---|
| cols | 1 | 2 | 3 | 3 | 4 | 4 |
| card | 368 | 278 | 249 | 293 | 234 | 314 |
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 thecard-grid.tsxinventory 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.
| File | Prop | Value |
|---|---|---|
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:
| Route | Sidebar label | PageHeader title |
|---|---|---|
/mi-cuenta/perfil | Mi perfil | Mi perfil |
/mi-cuenta/cursos | Mis cursos | Mis cursos |
/mi-cuenta/facturas | Mis facturas | Mis facturas (was "Mi factura", 3 call sites in facturas/page.tsx) |
/mi-cuenta/certificados | Mis certificados | Mis certificados |
/mi-cuenta/credencial | Mi credencial | Mi 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
| # | Section | Type | Notes |
|---|---|---|---|
| 1 | PageHeader "Mi perfil" | — | subtitle → "Tus datos, tu forma de ingresar y tus puntos." ("cartera" is internal vocabulary and no section is called that any more) |
| 2 | Points banner (AccountPointsBanner) | banner | balance > 0 only; full width of the stack (§4.5) |
| 3 | Datos personales | AccountSectionCard | name · birth date · education level · gender |
| 4 | Acceso y contacto | AccountSectionCard | email (read-only) · phone (OTP flow) |
| 5 | Datos de pago | AccountSectionCard | Stripe billing identity · saved cards |
| 6 | Movimientos de puntos | AccountSectionCard | the points ledger |
Reads as: who I am → how I get in and how you reach me → how I pay → what 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):
ProfileFormdrops the emaildt/dd(view) and the emailInput+ note (edit). Itsdlbecomes 4 items = 2 clean rows atsm:grid-cols-2.PhoneChangestops rendering its ownAccountSectionCardin 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-softblends 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
| Where | Shipped | Spec | Why |
|---|---|---|---|
| Datos de pago | Titular | keep | correct for a billing holder, parallel with "Correo de facturación" |
| Datos de pago | Correo de facturación | keep | — |
| Datos de pago | Cliente desde | Facturació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 pago | Tarjetas guardadas | keep (render only when non-empty — already true) | — |
| Datos de pago | <Badge variant="success">Cliente activo</Badge> | remove | Signals 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 dt | type-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:
| Slot | Skeleton |
|---|---|
| header | <PageHeader size="section" title="Mi perfil" subtitle="" loading /> (no grid wrapper) |
| points banner | h-28 (112px) |
| Datos personales | h-72 (288px) |
| Acceso y contacto | h-48 (192px) |
| Datos de pago | h-60 (240px) |
| Movimientos de puntos | h-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-softtints, notbg-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): itsPhoneReminderBannerClientCTA, labeled "Agregar teléfono", is a defaultButton(bg-primary/accent). Zero accent moments only once that banner has been dismissed or doesn't apply. - Transient:
ProfileForm's "Guardar cambios" andPhoneChange'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
h2under the pageh1; no skipped level (wash3). - 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-*, existingtype-*roles, existing color tokens). The one primitive change is a rename + rebreakpoint of an existingCardGridcva variant.
7. Open — José's call, not the Designer's
- "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.
- 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. - Points banner loses
max-w-lg(§4.5).max-w-lgwas 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 keepmax-w-lgand accept the ragged first row. - 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:
- "Acceso y contacto" (§4.2) — confirmed as spec'd.
ProfileFormandPhoneChangemerge ONLY their card chrome (oneAccountSectionCardcomposed 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. - Phone-reminder banner moves off perfil (§4.6) — rejected. The banner stays exactly
where it renders today:
/mi-cuenta/perfilonly, 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 ownmb-6, redundant against the stack'sgap-8) was still fixed, since it renders unchanged on perfil. - Points banner loses
max-w-lg(§4.5) — confirmed as spec'd, including theactionslot forPointsInfoModal's "Acerca de los puntos" trigger. - 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-5→cards-4 CardGrid rename, the title
casing sweep, the PhoneChange no-phone empty-branch fix, the AccountSectionCard title fix, the
type-meta→type-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:
| Source | Effect |
|---|---|
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:
| Viewport | gutter (each side) | content track | inside a padding="md" card |
|---|---|---|---|
| 360 | 30 | 300 | 252 |
| 390 | 32.5 | 325 | 277 |
| 430 | 35.8 | 358 | 310 |
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)
| Row | Minimum width it needs | Available | Verdict |
|---|---|---|---|
OtpCodeInput (phone-change step 3) | 6 × min-w-10 (240) + separator (px-1 + w-3 = 20) + 2 × root gap-2 (16) = 276px | 252 | overflows 24px |
ProfileForm edit footer ("Guardar cambios" + "Cancelar") | ≈115+32 and ≈62+32 labels at the portal's 24px button padding, + gap-2 = ≈281px | 252 | overflows 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
| Option | Why not / why |
|---|---|
Segmented primitive | Wrong 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 bar | Reject. 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 / drawer | Reject. 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 content | Reject. 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 links ✅ chosen | Keeps 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)
| State | Treatment |
|---|---|
| rest | text-fg-secondary |
| hover (pointer) | hover:bg-sunken hover:text-fg-primary, colors only |
| active | bg-sunken text-fg-primary + aria-current="page" |
| focus-visible | the primitive ring (focus-visible:ring-3 ring-ring/50) — must be added; the current links have no focus style at all |
| disabled | n/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:
| # | Item | Decision |
|---|---|---|
| 12.1 | Input 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.2 | Button 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.3 | Card padding — AccountSectionCard | p-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.4 | OTP slot floor — OtpCodeInput | min-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.5 | OTP field font size | Add 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.6 | FormFooterActions 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.8 | Header 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.
| File | Change |
|---|---|
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.tsx | gap-8 → gap-4 in the stacked direction (§10.4). Nothing else. |
components/account-section-card.tsx | p-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.tsx | Read 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.tsx | No 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.tsx | Stacks 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.tsx | No layout change (max-w-4xl is inert below md). Ledger bleed follows §12.3. |
mi-cuenta/cursos/page.tsx · certificados/page.tsx | CardGrid 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.tsx | Unchanged — the skeleton heights (h-96 course / h-44 certificate / the perfil stack) still match the real anatomy at one column. |
14. Recommended, not specced — José's call
- Container queries for the field pairs.
ProfileForm'ssm:grid-cols-2keys 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 atype-labellike "ÚLTIMO GRADO DE ESTUDIOS" (~168px) does not fit its 152px column. The correct tool is a container query on the card (@containeronAccountSectionCard,@lg:grid-cols-2on thedl— 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 forCardGridby re-breakpointing — container queries would have fixed it at the root. - The 640–1023 tablet band for
cards-4.lg:grid-cols-2means 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 sharedCardGridvariant. Flagged, not specced, because it re-opens a primitive José ratified in §8 two days ago. - 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.
FormFooterActionssub-44px targets (§12.6) — kept compact deliberately; needs a real phone to judge.- 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 nav —
AccountSidebarrenders the horizontal scrollable rail belowmdexactly per §10.2 (account-sidebar.tsx);mi-cuenta/layout.tsx's stacked gap drops togap-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) forOtpCodeInput.FormFooterActionsitself (§12.6) is untouched, as specced. - §9.2/§12.1 iOS zoom — the shared
[data-slot='input']rule steps to1rembelowmd. - §11 mobile gutter — both files fixed as specced: the
--inset-bleedtoken gains its< mdstep (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.
AccountSectionCardis the@containerancestor (container-type: inline-sizevia the@containerutility on itsCardframe);ProfileForm's twosm:grid-cols-2pairs (the read-modedland the edit-mode birth-date/education-level pair) both switch to@lg:grid-cols-2(the container-querylg= 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-columndl), so no change was needed there. - §14.2 the tablet band for
cards-4— REOPENED, now decided.CardGrid'scards-4variant switched to the spec's own literal recommendation —@md:grid-cols-2 @3xl:grid-cols-3 @5xl:grid-cols-4— overlg:/xl:/2xl:. The single@containerancestor both mi-cuenta list pages (cursos,certificados) now share ismi-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.
AccountSidebarwraps itsmd+column in the neutralCard(zero footprint belowmd, chrome only atmd:, so the mobile rail — §10 — is untouched);mi-cuenta/layout.tsx's sticky offset movedtop-24→top-28(112px), a clean 32px gap below the header's actualh-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 perBackLink's own contract). The "Inscrito el 3 sep 2026" missing-space bug wasformatDate/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-betweenshape asPhoneChange's rows, amax-w-mdcap on itsdl(narrower than the explainer paragraph's existingmax-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":AccountPointsBannersplits into two explicit variants — the static<div>(no dialog,earnRate <= 0) andAccountPointsBannerButton(a real<button type="button">, composed asPointsInfoModal'sDialogTrigger asChildchild via its new optionalchildrentrigger slot). Zero hand-rolled a11y — focusability, Enter/Space, andaria-haspopup/aria-expanded/data-stateall 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 inerthintslot. 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.
CardGridgains acards-3-holdervariant (container-query 1→3, same mechanism ascards-4minus its last step) —certificadoskeepscards-4unchanged. A newCursosViewclient leaf (@/components/cursos-view.tsx) adds aSegmentedgrid/list toggle over the already-fetched course list; list mode usesListRow(course title/kind/state/enrolled-date, no persistence). shadcn registry checked:toggle-groupfits a generic multi-select, not a single in-page mode switch — the existingSegmentedprimitive (alreadyrole="tablist"/role="tab") is the better, already-adopted fit. - Mis Pagos rename.
/mi-cuenta/facturas'sPageHeadertitle and the matchingaccount-sidebar.tsxnav label both renamed "Mis Facturas" → "Mis Pagos" (route segment unchanged). A per-row "Facturar" column is CODE-GUARDED behind aBILLING_CONNECTED = falseconstant (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 getsmax-w-xs truncateso an outlier-length name ellipsizes instead of stretching the@lg:grid-cols-2field-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
| # | Shipped | Why it doesn't land |
|---|---|---|
| 16.1 | bg-linear-to-br from-warning-soft via-surface to-accent-soft | Crosses 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.2 | size-16 rounded-lg filled square, 36px glyph | The 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.3 | type-hero figure (44px in .editorial) + inline type-h2 unit | 44px 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.4 | No motion at all | Its 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.5 | The 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.6 | Whole 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:
| Token | Where | Value source |
|---|---|---|
--color-warning | medal glyph, type-label eyebrow | foundation theme.css (#b45309) |
--color-warning-soft | medal fill, hover wash, the radial | foundation (8% amber) |
--tone-amber-soft / -edge | medal halo ring / card hairline | foundation :root (8% / 22% amber) |
--color-surface | card base under the wash | .client.editorial (#ffffff) |
--color-fg-primary/-secondary/-muted | figure / worth line / hint | .client.editorial navy family |
--ring | focus ring | .client.editorial navy (≈14:1 on canvas) |
--ease-out, --duration-hover/-press | all transitions | foundation + .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+)
| Part | Spec |
|---|---|
| root | rounded-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) |
| medal | size-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 |
| eyebrow | type-label text-warning — "Tus puntos" (unchanged copy) |
| figure | flex 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 |
| worth | type-small text-fg-secondary — Equivalen a {redeemValue} en tu próxima compra. Rendered only when redeemValue > 0 |
| hint | sm: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 block | flex min-w-0 flex-col gap-1 |
Three deliberate changes from shipped:
rounded-lgsquare →rounded-pilldisc (R5) atsize-14+ring-8amber 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).- Figure
type-hero→type-display(44 → 52px).--text-displayis the one role.client.editorialdoes not bump, so 52px is the same number on every surface. Against the page'stype-sectiontitle (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 componentsclasses, sosm:type-displaydoes not exist — and at 296px of card interior a 6-character figure at 52/600 measures ≈180px, so it fits the phone unaided. - The worth line is new copy.
balance × pointValue, formattedes-MXcurrency. 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.
| Beat | Trigger | Params | Why |
|---|---|---|---|
| Entrance | mount | animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out — no delay | 8px 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 glint | 700ms after mount, once | translateX(-160% → 320%) skewX(-18deg), 900ms --ease-out, fill-mode: both | The 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 / press | pointer | card 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
| State | Treatment |
|---|---|
| rest | bg-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-visible | outline-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 / press | medal group-active:scale-95, 120ms (--duration-press). Card itself does not scale |
| dialog open | data-[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 |
| loading | perfil/loading.tsx — the banner skeleton grows h-28 → h-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 |
| error | n/a — a server read; the page's !ok branch owns it (ReadError) |
| disabled | n/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 motion | entrance + 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/perfil | Family | Accent count |
|---|---|---|
| Points banner (this spec) | warning + neutral surface/fg | 0 |
ProfileForm "Editar perfil" | Button variant="outline" | 0 |
PhoneChange "Cambiar / Agregar teléfono" | Button variant="outline" | 0 |
PhoneReminderBanner | unchanged from §8.2 | 0 |
| Rest-state total | 0 |
- This spec improves the count: the shipped
to-accent-softgradient stop was the banner's one reference to the accent family (§6 read it as zero because it is a 10% tint, not abg-accentsurface — a defensible but borderline call). Removing it makes the reading unambiguous rather than argued. - The focus ring resolves to
--ring, which.client.editorialsets 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" andPhoneChange'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 onwarninginstead 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:
warninghere 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
PointsInfoModaltrigger 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. Theaction?: React.ReactNodeprop added in §4.5 goes away with it. PointsInfoModalkeeps its own defaultTextLinktrigger for its other consumer,apps/portal/src/components/cart-view.tsx— and §12.7'smin-h-11touch-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
...propsonto the root and acceptrefas a normal prop (F1, noforwardRef) —DialogTrigger asChildwiresonClick/aria-*/data-statethrough 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
| File | Change |
|---|---|
apps/portal/src/components/account-points-banner.tsx | Full rewrite per §17 + §19.2: shared body, two variants, action prop removed, redeemValue/hint added, data-slots |
apps/portal/src/components/points-info-modal.tsx | Optional children trigger slot; default trigger unchanged (cart consumer) |
apps/portal/src/app/(portal)/mi-cuenta/perfil/page.tsx | The composition above; balance > 0 guard hoisted here |
apps/portal/src/app/(portal)/mi-cuenta/perfil/loading.tsx | Banner skeleton h-28 → h-40 |
apps/portal/src/app/portal-theme.css | The two token-layer blocks (§17.3 wash, §17.4 glint + guard) |
20. Open — José's call, not the Designer's
- Coral instead of amber, and only if both banners move together.
.client.editorialships 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-warningdoing 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)CoursePointsBannerwould have to move with it or the two "puntos" surfaces diverge. If wanted: it is a token swap in §17.2/§17.3 (warning-soft→contrast-container,text-warning→--contrast-on-container,--tone-amber-*→ coralcolor-mixes) across both files, no structural change. - The zero-balance state.
balance <= 0still 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. - Figure at
type-display(52px). §4.5 shippedtype-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: keeptype-hero. - 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
::afterblock — 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)
| §20 | Decision | Effect on the spec |
|---|---|---|
| 1 | Amber 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. |
| 3 | The glint ships as spec'd (§17.4). | None. (Scoped in §21.5 so it fires only on the earned state.) |
| 2 | The 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+ unittype-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-14 + figure ≈48 (44 × 1.1 editorialtype-heroleading) + 4 + worth ≈23 = 95, + 48 padding = ≈143px.h-36(144px) is the exact match —h-28→h-36, superseding §17.6'sh-40. [EYES] - §20.3 is closed. No responsive type step is needed or possible (
type-*are@layer componentsclasses — §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. │
└──────────────────────────────────────────────────────────────────────────┘
| Part | Earned state (§17.2) | Empty state |
|---|---|---|
| root frame | rounded-xl border p-4 sm:p-6, same layout | identical — same radius, padding, flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6 |
| root border | border-(--tone-amber-edge) | border-border-default |
| root background | bg-surface + the amber radial wash (§17.3) | bg-surface, no wash — the radial rule is scoped off it (§21.5) |
| medal | bg-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 glint | fires once at +700ms | none — nothing to celebrate yet (§21.5) |
| eyebrow | type-label text-warning "Tus puntos" | type-label text-fg-muted "Tus puntos" — same string, so it reads as the same object |
| figure slot | type-hero balance + type-body unit | type-h1 text-fg-primary headline: "Gana tus primeros puntos" |
| worth line | type-small text-fg-secondary "Equivalen a $X …" | replaced by the earn line (below) — see §21.4 |
| earn line | — | type-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:
| State | Root element | Destination |
|---|---|---|
| earned | <button> via DialogTrigger asChild | PointsInfoModal |
| empty | <a> (next/link) | /tienda — where a purchase begins |
| no programme | <div> (§19.2, unchanged) | none |
Reasoning:
- 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.
- 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.
- 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:
balance | earnRate | Renders | Root | Interaction |
|---|---|---|---|---|
| > 0 | > 0 | earned card | <button> | opens the dialog |
| > 0 | ≤ 0 | earned card (§19.2, unchanged) | <div> | none |
| ≤ 0 | > 0 | empty card | <a> | navigates /tienda |
| ≤ 0 | ≤ 0 | nothing — 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)
| State | Treatment |
|---|---|
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 / press | identical to the earned card (ring-3 ring-ring/50 ring-offset-2; medal group-active:scale-95) |
| empty entrance | identical (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 |
| loading | h-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
| File | Additional change from §21 |
|---|---|
apps/portal/src/components/account-points-banner.tsx | Third 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.tsx | The §21.5 four-row matrix (replaces §19.2's two-row ternary) |
apps/portal/src/app/(portal)/mi-cuenta/perfil/loading.tsx | Banner skeleton h-28 → h-36 (was h-40 in §17.6) |
apps/portal/src/app/portal-theme.css | Both blocks gain :not([data-tone='empty']) (§21.5) |
apps/portal/src/components/points-info-modal.tsx | Unchanged 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 | #f0f0ee | 1.06:1 |
apps/portal/src/app/portal-theme.css (125/127/261) | #f7fbfd | #eaf6fc | 1.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)
| Candidate | Portal | Foundation | On a bg-sunken host | Verdict |
|---|---|---|---|---|
bg-muted (today) | 1.06:1 | 1.06:1 | 1.09:1 | invisible |
bg-sunken (one step down) | 1.15:1 | 1.14:1 | 1.00:1 | better, but dies on sunken hosts |
bg-foreground/10 | 1.20:1 | 1.23:1 | ~1.2:1 | adopted — 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).
| Element | From | To | Why |
|---|---|---|---|
tenant-header.tsx action-cluster wrapper | gap-3 | gap-4 | 16px, on base-8; separates the two functions |
user-menu.tsx wrapper | gap-2 | gap-2 | unchanged — 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:
| Principle | Failure |
|---|---|
| Responsibility | Solid 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. |
| Purpose | The 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. |
| Craft | Hue 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
| Surface | Resolves to | Counts? |
|---|---|---|
| Cart icon (ghost, rest) | fg-secondary | no |
| Cart count badge (when items exist) | --contrast coral | the header's one attention moment, conditional |
"Mi Cuenta" (ghost, or secondary when active) | bg-elevated | no |
| Sign-out (ghost, rest) | fg-muted | no |
| Sign-out hover | danger/10 | no — 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
| File | Change |
|---|---|
packages/ui/src/components/button.tsx | ghost variant: hover:bg-muted → hover:bg-foreground/10, add active:bg-foreground/15 (§22.2). Nothing else in the cva changes |
apps/portal/src/components/tenant-chrome/tenant-header.tsx | action-cluster wrapper gap-3 → gap-4 (§22.3) |
apps/portal/src/components/user-menu.tsx | sign-out variant="spotlight" → variant="ghost" + the §22.4 className state pair; wrap in Tooltip; wrapper gap-2 unchanged |
.claude/rules/foundation-ui-conventions.md | one 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
| # | Finding | Why it matters |
|---|---|---|
| 1 | Two 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. |
| 2 | The 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. |
| 3 | No 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:
| Option | Verdict |
|---|---|
| Right, full-height, flush ✅ | The 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 sheet | Reject. 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):
| Viewport | Panel | Page strip left visible | Panel content box (p-4) |
|---|---|---|---|
| 360 | 300 | 60 | 268 |
| 390 | 320 (capped) | 70 | 288 |
| 430 | 320 (capped) | 110 | 288 |
| 767 | 320 (capped) | 447 | 288 |
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"+ HugeIconsCancel01Icon(strokeWidth={2}, matching the header's other icons), wrapped inDrawerClose asChild.size-11(44px) overridesicon-lg'ssize-9through twMerge while the variant's[&_svg]:size-5glyph 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.
DrawerTitlestayssr-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. Passaria-describedby={undefined}onDrawerContentif 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:
| Element | Class | Height |
|---|---|---|
| Site nav row | type-body (15/400) + font-medium when active | min-h-11 (44) |
| Group label | SectionLabel (type-label, mono UPPER, fg-secondary — fg-muted fails AA below 24px, #492) | — |
| Account row | type-small (14/400) + font-medium when active, gap-3 icon→label | min-h-11 (44) |
| Between groups | gap-6 (24) | — |
| Within a group | gap-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
| Element | State | Treatment |
|---|---|---|
| Trigger (hamburger) | rest | ghost, text-fg-secondary; 36px box / 44px hit area |
| hover | bg-foreground/10 — inherited from §22.2's variant fix, nothing at the call site | |
| active (press) | bg-foreground/15 (§22.2) | |
| focus-visible | the Button base ring (focus-visible:border-ring ring-3 ring-ring/50) | |
| panel open | no styling — the panel covers it; the X is its open state | |
| Panel | closed | not mounted (Radix) |
| open | bg-surface, border-l border-border-default, rounded-l-xl, shadow-md, z-(--z-modal), overlay bg-ink/50 — all from the primitive | |
| Nav row | rest | text-fg-secondary |
| hover | bg-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 route | bg-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-visible | focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none (as today) | |
| disabled | n/a — a route is never disabled | |
| Account group | signed out | the whole <nav> is absent (not hidden, not empty-stated) — it is not a state of this panel, it is a different panel |
| Close button | rest / hover / active / focus | ghost 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
| Property | Value | Why |
|---|---|---|
| Enter | translateX + fade, 200ms, --ease-out | Emil'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 |
| Exit | 150ms, --ease-out | Dismissal is a system response, not a decision — exit is always snappier than enter |
| Overlay | the primitive's fade-in-0 / fade-out-0, unchanged | opacity only |
| Row press | bg-foreground/15, colours only, --duration-hover (140ms) from the Button/base transition | A full-width row that scales looks like a layout bug; the state layer is the feedback |
| Stagger | none | 9 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 motion | nothing to add | Globally guarded in packages/ui/src/tokens/motion.css (see drawer.tsx's own note) |
| Drag-to-dismiss | not this pass | Radix 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).
23.7 The footer — and the one recommendation that reaches outside this component
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
| Surface | Resolves to | Counts? |
|---|---|---|
| Every nav row (rest / hover / press) | fg-secondary / foreground/10 / foreground/15 | no |
| Current-route row | bg-sunken | no — a neutral ramp step, not the accent |
| Group label | fg-secondary | no |
| Close button | fg-secondary | no |
| "Cerrar sesión" hover | danger/10 | no — a status tone, never the accent (G17), and transient |
| "Inscríbete" (signed out only) | primary → portal coral | 1 — the panel's single accent moment |
Signed in: 0. Signed out: 1. Correct either way.
23.9 Rubric — where principles pulled against each other
| Principle | Call |
|---|---|
| Agency | The X exists, lands on the trigger's coordinates, and is 44px. Overlay tap + Escape kept. |
| Simplicity | The 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. |
| Familiarity | Standard right sheet, standard label-grouped list, session exit at the bottom. No novel gesture to learn. |
| Craft | Close-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, named | The 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, named | José'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
| File | Change |
|---|---|
apps/portal/src/components/tenant-chrome/mobile-nav.tsx | Rewrite 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.tsx | Only 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.tsx | Recommended, 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.