Operator nav / IA — the single-rail dashboard chrome
:::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. :::
reference· designer · updated 2026-09-05 · source
The Design Spec for issue #1602: replace the operator surface's dual-rail chrome
(@tedos/ui/shell Shell + PrimaryRail + SecondaryNav) with the single-rail
@tedos/ui/dashboard chrome, and decide the operator's navigation IA while doing it (José:
"I don't like the current state" — this is a product/IA decision, not a mechanical port).
Frontend implements from this doc; Designer reviews the rendered result before close (design-routing). This is IA + composition contract + tokens + states + motion, not pixel layout. Rubric: design-principles.md. Grid: grid-system.md. Hues: ../.claude/rules/color-scope.md. Inventory: ../.claude/rules/foundation-ui-conventions.md. Sibling spec for the other operator surface: console-nav-ia-spec.md. Surface map: tedos-portal-screen-map.md.
0. Decision summary
| # | Question | Decision |
|---|---|---|
| 1 | Nav IA (groups, labels, order) | 5 sections, 13 rows. A task-first Pendientes group is lifted to the top; the module groups (Educación, Pagos) hold the places you go; Sitio web + Conexiones pin to a bottom unlabeled shelf. §1 |
| 2 | How grouping renders | Hand-composed over the low-level DashboardSidebar* primitives via one app-local OperatorNavSection. NOT DashboardNavMain / NavSecondary / NavDocuments — none of them can render an ACTIVE row. §2 |
| 3 | Module hue (moduleTileVars, neon dock, frame tint) | Retired on the operator surface. Active row = neutral tonal step (bg-sunken), never a hue. The --module-* token scales STAY (course-card.tsx still uses them). §3 |
| 4 | Fate of @tedos/ui/shell | Keep — apps/console still consumes Shell, and the operator still consumes SearchCommand / SearchTrigger / NotificationBell from that subpath. Only PrimaryRail / SecondaryNav / nav-collapse lose their production consumer; do NOT delete them in this PR. §7 |
| 5 | Site header, bell, ⌘K, tenant brand | Site header = sidebar trigger + section title (derived from the nav config) + help HoverCard + actions (search box, then bell). Tenant lockup moves to the sidebar brand slot (logo and name — it finally gets a name). ⌘K palette unchanged. §4 |
1. The nav IA (implementable listing)
Shape: OPERATOR_NAV_SECTIONS: readonly OperatorNavSection[] replaces OPERATOR_NAV_GROUPS.
interface OperatorNavItem {
/** Route segment under `/operator` (`''` = the index page). */
segment: string
label: string
icon: IconSvgElement
/** Shown in the site header's help HoverCard + as the ⌘K row's second line. */
description?: string
}
interface OperatorNavSection {
key: string
/** Group overline. Omit for an unlabeled section. */
label?: string
/** `true` = pinned to the sidebar bottom (`mt-auto`). At most ONE section. */
pinned?: boolean
items: readonly OperatorNavItem[]
}
OperatorNavGroup.icon and OperatorNavGroup.moduleKey are deleted — group headings are text,
not tiles, and the hue system is retired (§3). moduleTileVars() is deleted from
operator-nav.ts; operatorHref / isOperatorSegmentActive / isOperatorPageActive /
operatorSearchItems survive (re-typed to the new shape; isOperatorPageActive's longest-match
rule is now applied across the whole section list, not within one group).
The sections
Section 1 — general, no label
| Label | segment | Route | Icon | description |
|---|---|---|---|---|
Resumen | '' | /operator | DashboardSquare01Icon | Detecta lo que necesita atención, revisa tendencias y confirma que las conexiones funcionen. |
Section 2 — pendientes, label Pendientes (Teléfonos por confirmar removed post-spec,
see §12 — was a third row here)
| Label | segment | Route | Icon | description |
|---|---|---|---|---|
Accesos por confirmar | student-binds | /operator/student-binds | UserCheck01Icon | Cuando un alumno entra al portal con un teléfono que el sistema no puede vincular con certeza a una persona (número compartido, reciclado o ambiguo), la solicitud espera aquí tu decisión. Al aprobarla, ese teléfono queda ligado a ese alumno. |
Pagos pendientes | payments/pending | /operator/payments/pending | Clock01Icon | La cola de trabajo del día: pagos sin conciliar y alumnos inscritos sin pago confirmado. |
Section 3 — educacion, label Educación
| Label | segment | Route | Icon | description |
|---|---|---|---|---|
Alumnos | students | /operator/students | UserMultiple02Icon | Las personas del proyecto, unificadas entre canales. |
Importar alumnos | students/import | /operator/students/import | FileImportIcon | Sube el archivo como lo tengas. Lo que no podamos usar se reporta, no se pierde. |
Cursos | courses | /operator/courses | CourseIcon | El catálogo de cursos y programas del proyecto: busca por nombre y filtra por materia y fecha. |
Etiquetas | tags | /operator/tags | Tag01Icon | Las etiquetas para categorizar cursos: crea, renombra y elimina tags. Asígnalas desde cada curso. |
Section 4 — pagos, label Pagos
| Label | segment | Route | Icon | description |
|---|---|---|---|---|
Movimientos | payments | /operator/payments | MoneyExchange01Icon | Todos los movimientos del proyecto: conciliación y CFDI por pago. |
Facturación | payments/billing | /operator/payments/billing | Invoice03Icon | Comprobantes fiscales (CFDI) de los pagos del proyecto: solicitados, emitidos y factura global. |
Ingresos | payments/revenue | /operator/payments/revenue | ChartIncreaseIcon | Ingresos conciliados por periodo y por curso, comparados con el periodo anterior. |
Section 5 — sitio, no label (repositioned post-spec, see §11 — was Section 6/bottom shelf)
| Label | segment | Route | Icon | description |
|---|---|---|---|---|
Sitio web | sitio-web | /operator/sitio-web | Globe02Icon | Las páginas del sitio web público de tu proyecto: crea, edita, publica u oculta cada página. |
Section 6 — utilidades, no label, pinned: true (now Conexiones alone, see §11)
| Label | segment | Route | Icon | description |
|---|---|---|---|---|
Conexiones | connections | /operator/connections | Link04Icon | Los conectores de este proyecto y sus credenciales. El engine actúa como el cliente — los tokens viven en el vault por tenant, nunca guardamos su contenido. |
Copy + icon changes (everything else is carried over verbatim)
| Change | Why |
|---|---|
Pagos (the module root page) → Movimientos | Pagos > Pagos was the loudest duplication in the old nav. The page IS the movements ledger (its own route doc calls it "the movements table"). |
Pendientes → Pagos pendientes | It leaves the Pagos section, so the label must carry its domain. |
Movimientos description drops "Las secciones … viven en el menú del módulo." | That sentence described the retired dock/secondary-nav shape. Em dash swapped for a colon. |
Resumen icon ChartColumnIcon → DashboardSquare01Icon | Resumen is a dashboard, not a chart page; ChartColumn also collided with Ingresos' chart glyph. |
Movimientos icon Invoice01Icon → MoneyExchange01Icon | Invoice01 and Invoice03 are near-identical glyphs two rows apart. Movements = exchange, invoicing = invoice. |
Group icons deleted: ChartColumnIcon, Book02Icon, Money01Icon, Settings02Icon | Group headings are type-label text now; there are no dock tiles to icon. |
Why this IA (and the trade-offs)
- Purpose. The operator's day is "who is waiting on me". Three queues (
student-binds,phone-queue,payments/pending) were buried among object lists in two different modules; they now open the sidebar as one named group. That is the substantive IA change, not a re-skin. - Simplicity. 15 rows / 4 headings → 13 rows / 3 headings. The
Sistema(1 page) andSitio web(1 page) one-item groups are gone; a one-item heading is chrome without content. - Trade-off named — Purpose vs Familiarity.
Pagos pendientessits underPendienteswhile its route is/operator/payments/pending, so nav parent ≠ route parent. Accepted: the label carries the word "Pagos", the site header still titles it "Pagos pendientes", and active-state matching keys on the segment, not the section. The reverse (queues scattered by module) costs the operator every morning; the URL mismatch costs a reader nothing. - Trade-off named — Simplicity vs the 2026-07-16 "Sitio web gets its own dock tile" call.
In a single rail every page is a permanently visible labeled row, so
Sitio webis strictly MORE visible than a dock tile that had to be hovered to be named — regardless of which unlabeled position it sits in. (§11: it was first placed in the bottom shelf with Conexiones, then moved to its own standalone row above that shelf after a UX research pass found the bottom-shelf position reads as "Settings" in every reference product, which Sitio web is not.) - Familiarity. Bottom-pinned unlabeled shelf = "the app's own tools" is the shipped
dashboard-01 / Linear / Slack convention;
Importar alumnoskeeps its documented adjacency toAlumnos(effort #1417's reasoning stands: a new tenant must find it on day one). - Flexibility (deferred, see §8). Module-gated sections (
requireModule('payments')) are NOT yet hidden per tenant — a follow-up, since it needs the tenant's module flags in the shell read.
2. How grouping renders (composition contract)
Decision: hand-compose over the low-level primitives. DashboardNavMain,
DashboardNavSecondary, and DashboardNavDocuments are all rejected for this surface, for one
hard reason plus two soft ones:
- Hard: none of them can render an active row.
NavMain/NavSecondaryemit<a href>inside aDashboardSidebarMenuButtonwith noisActiveand noaria-current. An app nav that can't show "you are here" is not a candidate. NavMainrenders exactly ONE unlabeled group — it has noDashboardSidebarGroupLabel— so it can't express sections 2–4 anyway.NavDocumentsis a recent-items list with a per-row "…" action menu. The operator nav has no per-row actions; using it would put a dead affordance on every row.
One app-local component, used for all five sections:
// apps/portal/src/components/operator/operator-nav-section.tsx ("use client")
<DashboardSidebarGroup className={section.pinned ? 'mt-auto' : undefined}>
{section.label ? <DashboardSidebarGroupLabel>{section.label}</DashboardSidebarGroupLabel> : null}
<DashboardSidebarGroupContent>
<DashboardSidebarMenu>
{section.items.map((item) => {
const active = isOperatorPageActive(pathname, item)
return (
<DashboardSidebarMenuItem key={item.segment}>
<DashboardSidebarMenuButton
asChild
isActive={active}
className="text-fg-secondary hover:bg-elevated hover:text-fg-primary data-[active=true]:bg-sunken data-[active=true]:text-fg-primary"
>
<Link href={operatorHref(item.segment)} aria-current={active ? 'page' : undefined}>
<HugeiconsIcon icon={item.icon} size={16} strokeWidth={2} aria-hidden="true" />
<span>{item.label}</span>
</Link>
</DashboardSidebarMenuButton>
</DashboardSidebarMenuItem>
)
})}
</DashboardSidebarMenu>
</DashboardSidebarGroupContent>
</DashboardSidebarGroup>
next/link + usePathname stay app-side — the foundation stays Next-free (A4/D3).
Layout contract (host-side classes — no package change needed)
The Dashboard* port dropped upstream's fixed inset-y-0 sidebar, so out of the box the sidebar
scrolls away with the page. The operator shell restores the app-shell metaphor: chrome pinned,
one scroll container, which is what the dual-rail shipped and what the inspector-panel screens
require (they need a height-definite parent).
<DashboardSidebarProvider defaultOpen={!collapsed} className="h-dvh overflow-hidden">
<DashboardAppSidebar brand={<OperatorTenantBrand … />} footer={<OperatorNavUser />}>
{OPERATOR_NAV_SECTIONS.map((s) => <OperatorNavSection key={s.key} section={s} />)}
</DashboardAppSidebar>
<DashboardSidebarInset className="overflow-hidden"> {/* variant="sidebar" — flush, NOT inset */}
<DashboardSiteHeader title={…} titleAside={…} actions={…} />
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</DashboardSidebarInset>
</DashboardSidebarProvider>
variant="sidebar"(flush), not"inset". The floating card only existed to separate light content from the DARK ink frame; on a light sidebar it is decoration without a job (Purpose).- Collapse persistence.
DashboardSidebarProviderhas no cookie and no ⌘B (deliberately, to avoid colliding with shell'snav-collapse). The host owns both: read the cookieoperator_nav_collapsedserver-side (await cookies(), B3) →defaultOpen; write it inonOpenChange; register ⌘B/Ctrl+B app-locally with the same input/contenteditable guard asuse-find-shortcut.ts. Keep the same cookie name so a user's existing preference carries over. Do NOT import shell'snav-collapse(§7). - Below
mdthe sidebar is a leftDrawer(built in), opened by the site header'sDashboardSidebarTrigger. The operator's bespoke ink mobile drawer is retired.
3. Module hue — retired on the operator surface
The dual-rail carried a per-module hue: moduleTileVars() fed --rail-glow for the neon active
dock icon + dock dot, and OperatorFrameTint GSAP-tweened the steel frame's --frame-h/--frame-c
per module. All of it is retired. Reasons:
- The single rail has no dock tiles. The only place a hue could land is the row icon — i.e. four category colors on one screen, which is exactly what the accent budget forbids ("status and category colors must live OUTSIDE the accent family", and a category hue per group is a rainbow, foundation-ui-conventions § Accent budget, G17).
- The ink frame goes away with the dual rail, so the frame tint has no surface to tint.
Keep the --module-* token scales in apps/portal/src/app/portal-theme.css — the
student-facing course-card.tsx reads them for subject hues. Only the nav stops consuming them.
Delete (app-local, no other consumer): moduleTileVars() + OperatorModuleKey in
operator-nav.ts, and operator-frame-tint.tsx.
Accent budget after this change: the sidebar spends ZERO accent. The active row is a neutral tonal step, so each operator screen's single accent moment stays available for its primary action.
4. Chrome placement map
| Piece | Was | Now |
|---|---|---|
| Tenant identity | 48px logo tile at the ink rail's top, no name | DashboardAppSidebar brand — size-8 rounded-md TenantLogo tile + the tenant name in type-h2 truncate, in a h-12 row. The 256px rail finally has room to NAME the workspace. |
| Nav collapse toggle | dock pin + a copy in every page band | ONE DashboardSidebarTrigger in the site header (+ ⌘B) |
| Page title | OperatorPageBand composed per route | DashboardSiteHeader title — derived from the nav config by pathname (deepest matching item's label). Routes stop composing headers. |
| Page description | help HoverCard inside the page band | same HoverCard, passed to the site header's new titleAside slot; copy comes from the matched item's description |
| ⌘K palette | OperatorCommand, mounted once in the shell | unchanged — mounted once in the new shell |
| Visible search box | SearchTrigger per page band | DashboardSiteHeader actions, first — <OperatorSearchBox className="h-9 w-72" />, hidden below md |
| Notifications | bell in the ink rail footer | DashboardSiteHeader actions, last (right edge) |
| Signed-in user | bare Clerk avatar in the rail footer; name only in a menu | DashboardNavUser in the sidebar footer — avatar + name + email + "…" menu. Wire onAccount → clerk.openUserProfile(), onLogout → clerk.signOut(); labels Cuenta / Cerrar sesión. Do NOT pass onBilling/onNotifications (no such operator surface) — see amendment A2. |
Site header title, precisely. It is the section/page anchor, never a dynamic entity name.
/operator/students/abc → "Alumnos"; /operator/courses/<id>/lessons → "Cursos". Entity interiors
keep their own in-content title (PageHeader + BackLink, unchanged) — the header stays the
"where am I" anchor while the content answers "which one". This is why no title plumbing is needed:
every title resolves from OPERATOR_NAV_SECTIONS.
Descriptions move to the nav config. Each route's existing SUBTITLE const is copied verbatim
into its nav entry's description (see §1 tables — they are already transcribed there) and the
const is deleted from the route. One source now feeds the help HoverCard AND (as a second line) the
⌘K rows. Routes with no description simply omit it and the help icon does not render.
OperatorPageBand (operator-page-band.tsx) is deleted, and every page.tsx drops its
const header = <OperatorPageBand … /> + the {header} render and the header prop it passes to
inspector-panel views.
5. Tokens, typography, states, motion
Type
| Element | Class |
|---|---|
| Section heading | type-label (mono UPPER 11/600), text-fg-muted — the primitive's default |
| Nav row | type-small sentence case — the primitive's default (NOT type-nav-label, which is the admin skin's mono nav look) |
| Site header title | type-h2 — the primitive's default |
| Brand name | type-h2 truncate |
| User row name / email | font-medium / type-small text-fg-muted — the primitive's default |
Nav row state matrix
| State | Treatment |
|---|---|
| default | transparent bg, text-fg-secondary, icon currentColor 16px / stroke 2 |
| hover | bg-elevated, text-fg-primary |
| focus-visible | ring-2 ring-sidebar-ring (primitive) — requires token amendment A4 so the ring resolves navy, not cyan |
| active (current) | bg-sunken, text-fg-primary, font-medium, aria-current="page" |
| active + hover | stays bg-sunken (no second change — the row is already the destination) |
| disabled | n/a — nav rows are never disabled (a module the tenant lacks is HIDDEN, §8, not greyed) |
Rationale: the primitive uses bg-sidebar-accent for BOTH hover and active, which in the portal
scope resolves to the same #eaf6fc — hover and "you are here" would be indistinguishable. Three
distinct tonal steps (transparent → elevated → sunken) separate them without spending accent.
Contrast in the .client.editorial scope: navy #1a2456 on #d9eef7 and on #eaf6fc both clear
AA comfortably; fg-secondary (70% navy) on white ≈5.5:1.
Motion
| Moment | Params |
|---|---|
| Nav row hover/active | transition-colors 150ms --ease-out (primitive default). Colors only. |
Sidebar collapse (md+) | Instant width swap — never a width transition (G6). Primitive default. |
Sidebar open (<md) | Drawer slide — transform/opacity (Radix + tw-animate-css). Primitive default. |
| Site header title on nav | Kept from the page band: gsap.fromTo(el, {autoAlpha:0, y:6}, {autoAlpha:1, y:0, duration:0.25, ease:'power3.out', clearProps:'opacity,visibility,transform'}), keyed on pathname; prefers-reduced-motion skips. Scoped to the title cluster — search + bell never animate. |
| Retired | dock hover magnification · the traveling rail dot · the GSAP frame tint · per-page-band title rise (moved, above) |
One motion moment survives, deliberately (Delight): the title's 6px rise is the surface's small piece of personality and costs nothing. Everything else was motion in service of the dual rail.
6. Required amendments (blocking — land with the swap)
| Id | File | Change |
|---|---|---|
| A1 | packages/ui/src/dashboard/site-header.tsx | Add titleAside?: ReactNode, rendered immediately after the <h1> and before the ml-auto actions cluster. Additive; the operator uses it for the help HoverCard. |
| A2 | packages/ui/src/dashboard/nav-user.tsx | Render the onAccount / onBilling / onNotifications / onLogout rows only when the handler is provided, and the pre-logout DropdownMenuSeparator only when ≥1 row precedes it. Today all four render unconditionally, so omitting a handler ships a menu item that does nothing (Craft + Familiarity fail). |
| A3 | packages/ui/src/dashboard/* baked chrome icons | strokeWidth={1.5} → {2} on the site-header trigger, nav-user's five icons, and nav-documents' four — the integer-stroke rule (José, 2026-07-13). Mixing 1.5 chrome with 2 nav rows in one sidebar is visibly inconsistent. |
| A4 | apps/portal/src/app/portal-theme.css (.client.editorial) | Add --sidebar-ring: #1a2456;. The foundation ships --sidebar-ring: var(--color-accent) → cyan #29b8e8, which measures ≈2.2–2.6:1 on the near-white canvas — below the 3:1 non-text bar for a focus indicator. The same file already sets --ring to navy for exactly this reason; the sidebar ring must follow. A11y gate, not polish. |
The only other consumer of the Dashboard* chrome is the showcase page from #1591, so A1–A3 are
low blast radius.
7. Fate of @tedos/ui/shell — KEEP, delete nothing in this PR
@tedos/ui/shell is not orphaned by this change:
| Export | Status after the swap |
|---|---|
Shell | live — apps/console (console-chrome.tsx, top-bar-driven header slot) |
SearchCommand, SHELL_OPEN_COMMAND_EVENT | live — console wrapper + the operator's OperatorCommand |
SearchTrigger | live — the operator's site-header search box |
NotificationBell | live — the operator's OperatorNotificationBell |
PrimaryRail, SecondaryNav, NavCollapse* | no production consumer — only apps/portal/src/app/dev/dock-preview |
Decision: keep the package intact. Do not delete the dual-rail components in the same PR that
swaps the chrome — a fast revert path is worth more than the dead code for one release. File a
follow-up to decide their fate (delete vs. keep as the documented parallel system) after the new
chrome has lived a release; the dev/dock-preview page is the thing that must go first either way.
Flag for Tech Lead — narrow the "import-disjoint" note.
foundation-ui-conventions § Dashboard says
"never mix Dashboard* chrome with @tedos/ui/shell chrome in one tree". After this swap the
operator legitimately imports SearchCommand / SearchTrigger / NotificationBell from
@tedos/ui/shell while running Dashboard* chrome. Those three are chrome-agnostic widgets that
merely live in the shell subpath, not layout. The note must be narrowed to the LAYOUT components
(Shell / PrimaryRail / SecondaryNav / nav-collapse); the durable fix is re-homing the three
widgets to the neutral @tedos/ui root — a separate issue, not this PR.
8. Deferred / follow-ups (not decided here)
- Module-gated sections.
requireModule('payments')gates pages at the route. With an always-visible flat list, a tenant without the payments module now sees aPagossection that errors. Design rule when it lands: a section whose module the tenant lacks is not rendered at all (never greyed — a disabled nav row is a promise you don't keep). Needs the tenant's module flags in the shell's server read; file separately. Reconfirmed live during implementation (a post-spec UX research pass, §11): flagged in the PR body for José rather than fixed in this PR — it's new plumbing (a module→section mapping + a second tenant-config read in the shell), not a one-line guard. - Queue counts.
Pendientesis the natural home for per-row counts ("Accesos por confirmar 3"). Deliberately out of scope: it needs three cheap count reads in the shell. Design note for when it lands: a neutral count on the right of the row (type-mono text-fg-muted), never adangerbadge — these are work items, not errors. - Deleting the dual rail +
dev/dock-preview(§7).
9. Rubric pass (design-principles.md)
| Principle | Where it lands |
|---|---|
| Purpose | Pendientes lifted to the top; one-item groups and the module-hue system cut because they served the chrome, not the operator. |
| Agency | Nothing is a wizard; collapse is manual and persisted; every destination is reachable in one click from any screen. |
| Responsibility | No new data asks. Untrusted tenant SVG still renders only through TenantLogo's <img data:> boundary. |
| Familiarity | Standard single-rail app shell (Linear/Slack/dashboard-01): brand top, sections middle, tools pinned bottom, identity in the footer. |
| Flexibility | md+ rail / <md Drawer; ⌘B + ⌘K keyboard paths; per-tenant module hiding named as the deferred personalization (§8). |
| Simplicity | 15 rows → 13; 4 headings → 3; one header instead of a per-route band; one title source instead of 15 SUBTITLE consts. |
| Craft | Tokens only, three distinct row states, aria-current, the A4 focus-ring fix, HugeIcons at 16/stroke 2, one scroll container. |
| Delight | Target emotion: calm and oriented. The tenant workspace is finally NAMED in the sidebar; the title's 6px rise is the one kept flourish. |
Tensions named: Purpose vs Familiarity on Pagos pendientes (§1) · Simplicity vs the prior
"Sitio web gets its own tile" call (§1) · Craft vs Speed on A1–A4, which are the floor, not polish.
10. Designer review checklist (before close)
- Active row is visibly distinct from hover on a real screen, in both light rooms and at 100% zoom.
- Focus ring is navy and visible on every nav row, the trigger, and the user row (A4 landed).
- Sidebar and site header stay pinned while a long list scrolls; only the content column scrolls.
- The inspector-panel screens (Alumnos, Pagos, Facturación, course interior) still get a height-definite parent and do not double-scroll.
- Site header title matches the deepest nav item for every route, including the four entity interiors; the help HoverCard shows the right description or no icon at all.
- Zero accent-colored surfaces in the chrome — count
accent/primaryin the shell: must be 0. <md: Drawer opens from the trigger, closes on selection, no horizontal scroll anywhere.prefers-reduced-motion: the title rise is skipped, nothing else moves.
11. Ratified 2026-09-05 (José, at implementation) — the "Needs confirmation" items closed
The five §0 decisions were implemented as spec'd, with one confirmed exception and one post-spec repositioning found during a UX research pass mid-implementation:
- Visual swap — confirmed, with one exception: the floating rounded content card is
KEPT (
DashboardSidebarInset variant="inset"), overriding §2'svariant="sidebar"guidance. §2 reasoned the card "only existed to separate light content from the DARK ink frame" and would be "decoration without a job" once that frame is gone — José's call: keep it anyway, as a deliberate visual-continuity choice on this surface, not a mechanical consequence of the frame. Everything else in §2/§3 (retire hover magnification, the traveling rail dot, the per-module neon glow, the GSAP frame tint; neutral nav icons, no module hue) stands as spec'd. - Grouping composition (§2) — confirmed as spec'd (hand-composed over the low-level
DashboardSidebar*primitives viaOperatorNavSection). Pendientescross-module task group — confirmed as spec'd.- Fate of
@tedos/ui/shell(§7) — confirmed: kept, nothing deleted from the package. The app-localapps/portal/src/app/dev/dock-preview/*dev-only preview route WAS deleted in this PR (not the package) — it directly imported the now-retiredOPERATOR_NAV_GROUPS/moduleTileVars/OperatorNavGroupshapes fromoperator-nav.tsand could not compile against the newOperatorNavSectionshape; its whole purpose (visualizing the retired dual-rail dock) was moot once the swap landed, and §7 already named it "the thing that must go first either way". Sitio webbottom-shelf placement (§1) — repositioned, not simply confirmed. A UX research pass run during implementation found the bottom-unlabeled-shelf position universally reads as "Settings/config" in every reference product (Linear, Notion, Slack all reserve it for that).Sitio webis recurring content-authoring work (the tenant's site editor), not configuration —Conexionesis genuine config and stays there alone. Sitio web moved to its own standalone, unlabeled section (§1 Section 5), positioned afterPagosand before the bottom shelf (which now holds onlyConexiones) — same icon/label/route, same treatment asResumen's top row. Row/heading counts are unaffected (still 13 rows / 3 headings — the item only changed sections, none were added or removed).
12. Teléfonos por confirmar removed from the standing nav (José, 2026-09-05)
Found live in the shipped Table rework of /operator/students/phone-queue (a follow-up polish
pass, not this PR's original scope): José reviewed the page and judged the whole "Pendientes"
placement wrong for this specific row — it only ever concerns a subset of imported students, and
he doesn't see a standing daily-work reason for it to occupy a permanent sidebar slot the way
Accesos por confirmar and Pagos pendientes do.
Removed from OPERATOR_NAV_SECTIONS (Section 2 now 2 rows, not 3 — 12 total nav rows, 3
headings). The route (/operator/students/phone-queue) and its screen are not deleted — the
underlying list (people missing a usable phone) is a live DB query, not a snapshot of one import
run, so it stays reachable at its own URL without a permanent nav slot; nothing about its
resumability required new persistence.
Resurfacing decided, not yet built (tracked as a separate follow-up issue, out of #1602's scope): the entry point moves to the Import screen — a contextual link/CTA (e.g. "Revisar N teléfonos pendientes") shown after running an import that left phone warnings, rather than a standing sidebar destination. That follow-up also covers: making Email a required COLUMN MAPPING at import (blank cells still import — same no-data-loss policy as phone today, never a per-row reject), and two bulk actions on the phone-queue table (bulk dismiss — needs a new status field, doesn't exist yet; bulk email reminder to rows that have an email). Deliberately NOT decided here: "bulk export selection" and "bulk resend" were considered and declined for now.