Nexo P1 polish — operator chrome out of user pages

The shell layout was rendering an operator-style header (Discover /
Matches / Profile / Safety / Devtools links + auth slot) on every
`/dating/*` route, including login, register, reset and the product
pages. That bar reads as a developer dashboard, not as the navigation
of a real dating app.

Layout (`+layout.svelte`):
- The header now mounts only when the route is under `/dating/admin`
  or `/dating/devtools` — i.e. the moderation / instrumentation
  surfaces, where operator-grade nav is correct. On every other
  surface the layout renders the children directly with the App
  composed and the session probe still running.
- Auth pages (login, register, reset, mfa) end up showing only their
  centered card; product pages (discover, matches, chat, profile,
  photos) get the full viewport with no chrome above.

`_components/AppNav.svelte`:
- Bottom-tab navigation for the product surface — Descubre / Matches
  / Perfil / Seguridad + Salir. Sticky-bottom on mobile, sits as a
  rounded bar at the foot of the viewport on desktop. Mounted by
  Discover, Matches and Profile so the user can still cross between
  top-level sections without the operator bar.

Chat keeps its own back-arrow (it's full-screen). Photos returns via
the back link in its header. Auth routes need no nav.

Gates: 1695 tests + check (0/0) + build + bundle 22.52 KB + aliases —
all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 35f484ea28
commit 9a1aa14ef7

@ -15,7 +15,7 @@
frontendTarget: () => appRoot frontendTarget: () => appRoot
}); });
const { App, api } = handle; const { api } = handle;
async function refreshSession(): Promise<DatingSessionResponse | null> { async function refreshSession(): Promise<DatingSessionResponse | null> {
try { try {
@ -48,51 +48,48 @@
onDestroy(() => handle.dispose()); onDestroy(() => handle.dispose());
const t = (key: Parameters<typeof App.lang.t>[0]) => App.lang.t(key); // Operator-style chrome (the cross-section nav with Discover /
// Matches / Profile / Safety / Devtools links) only makes sense
// once the user is operating an admin / devtools surface. Regular
// user pages own their own navigation context — auth pages render
// a centered card; product pages drive cross-section nav from
// inside their own UI. Activate the chrome only under
// `/dating/admin` and `/dating/devtools`.
const showChrome = $derived(
page.url.pathname.startsWith('/dating/admin') ||
page.url.pathname.startsWith('/dating/devtools')
);
// Plain hrefs (not the typed `resolve(...)`) so the layout compiles function isCurrent(href: string): boolean {
// before each phase has materialised its own `+page.svelte`. The return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
// links 404 until the route is created — same UX as opening any }
// unbuilt section of an SPA.
const navItems = $derived([
{ href: '/dating/discover', label: t('nexo.nav.discover') },
{ href: '/dating/matches', label: t('nexo.nav.matches') },
{ href: '/dating/profile', label: t('nexo.nav.profile') },
{ href: '/dating/safety', label: t('nexo.nav.safety') },
{ href: '/dating/devtools', label: t('nexo.nav.devtools') }
]);
const isAuthenticated = $derived(session?.authenticated === true); const isAuthenticated = $derived(session?.authenticated === true);
const userName = $derived( const userName = $derived(
session?.authenticated === true ? session.user.displayName || session.user.email : '' session?.authenticated === true ? session.user.displayName || session.user.email : ''
); );
function isCurrent(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
</script> </script>
<svelte:head> <svelte:head>
<title>Nexo — {t('nexo.tagline')}</title> <title>Nexo</title>
</svelte:head> </svelte:head>
<div bind:this={appRoot} class="nexo-shell"> <div bind:this={appRoot} class="nexo-shell" class:has-chrome={showChrome}>
{#if showChrome}
<header class="nexo-header"> <header class="nexo-header">
<a class="brand" href="/dating"> <a class="brand" href="/dating">
<strong>{t('nexo.title')}</strong> <strong>Nexo</strong>
<span class="tagline">{t('nexo.tagline')}</span> <span class="tagline">consola de moderación</span>
</a> </a>
<nav aria-label="Nexo"> <nav aria-label="Nexo">
{#each navItems as item (item.href)} <a href="/dating/admin" class="nav-item" aria-current={isCurrent('/dating/admin') ? 'page' : undefined}>
<a Moderación
href={item.href} </a>
class="nav-item" <a href="/dating/devtools" class="nav-item" aria-current={isCurrent('/dating/devtools') ? 'page' : undefined}>
aria-current={isCurrent(item.href) ? 'page' : undefined} Devtools
>
{item.label}
</a> </a>
{/each} <a href="/dating/discover" class="nav-item">Volver a la app</a>
</nav> </nav>
<div class="auth-slot"> <div class="auth-slot">
@ -109,14 +106,12 @@
} }
}} }}
> >
{t('nexo.auth.logout')} Cerrar sesión
</button> </button>
{:else}
<a class="ghost" href="/dating/login">{t('nexo.auth.login')}</a>
<a class="primary" href="/dating/register">{t('nexo.auth.register')}</a>
{/if} {/if}
</div> </div>
</header> </header>
{/if}
{#if loadError !== null} {#if loadError !== null}
<aside class="banner error" role="alert"> <aside class="banner error" role="alert">
@ -185,25 +180,16 @@
font-size: 0.85rem; font-size: 0.85rem;
opacity: 0.7; opacity: 0.7;
} }
.auth-slot a,
.auth-slot button { .auth-slot button {
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.35rem 0.7rem; padding: 0.35rem 0.7rem;
border-radius: 6px; border-radius: 6px;
text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
border: 1px solid transparent;
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
color: inherit; color: inherit;
} }
.auth-slot .ghost {
border-color: color-mix(in srgb, currentColor 25%, transparent);
}
.auth-slot .primary {
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
}
.banner { .banner {
margin: 0.75rem 1.25rem 0; margin: 0.75rem 1.25rem 0;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
@ -224,4 +210,12 @@
flex: 1; flex: 1;
padding: 1.5rem 1.25rem; padding: 1.5rem 1.25rem;
} }
/*
* On user-facing surfaces the layout reserves no chrome, so pages
* (Discover, Matches, Chat, Profile…) get the full viewport for
* their own product navigation.
*/
.nexo-shell:not(.has-chrome) .nexo-main {
padding: 1rem;
}
</style> </style>

@ -0,0 +1,124 @@
<!--
Bottom-tab navigation for the product surface (Discover, Matches,
Profile, Safety, plus a logout). Pages opt-in by mounting it; the
operator-style header is rendered by the layout only on
admin/devtools, so this is the regular user's only cross-section
nav.
Bottom-fixed on mobile, sticky on desktop. Tab labels stay short
so the bar is finger-sized on phones.
-->
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { getNexoContext } from '../_lib/context';
const nexo = getNexoContext();
const items = [
{ href: '/dating/discover', label: 'Descubre', icon: '✦' },
{ href: '/dating/matches', label: 'Matches', icon: '♡' },
{ href: '/dating/profile', label: 'Perfil', icon: '◔' },
{ href: '/dating/safety', label: 'Seguridad', icon: '⚐' }
] as const;
function isCurrent(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
async function logout() {
try {
await nexo.api.logout();
} finally {
nexo.setSession({ authenticated: false });
await goto('/dating/login', { replaceState: true });
}
}
</script>
<nav class="appnav" aria-label="Nexo">
<ul>
{#each items as item (item.href)}
<li>
<a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>
<span class="icon" aria-hidden="true">{item.icon}</span>
<span class="label">{item.label}</span>
</a>
</li>
{/each}
<li class="spacer" aria-hidden="true"></li>
<li>
<button type="button" class="logout" onclick={logout}>
<span class="icon" aria-hidden="true">⏻</span>
<span class="label">Salir</span>
</button>
</li>
</ul>
</nav>
<style>
.appnav {
position: sticky;
bottom: 0;
z-index: 20;
margin: 1.5rem -1rem -1rem;
background: color-mix(in srgb, canvas 92%, currentColor 8%);
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.appnav ul {
list-style: none;
margin: 0;
padding: 0.4rem 0.6rem;
display: flex;
gap: 0.15rem;
max-width: 40rem;
margin-inline: auto;
}
.spacer {
flex: 1;
}
.appnav a,
.appnav button {
font: inherit;
text-decoration: none;
color: inherit;
background: transparent;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
padding: 0.45rem 0.7rem;
border-radius: 8px;
font-size: 0.78rem;
min-width: 3.6rem;
}
.appnav a[aria-current='page'] {
background: color-mix(in srgb, currentColor 10%, transparent);
font-weight: 600;
}
.appnav .icon {
font-size: 1.1rem;
line-height: 1;
}
.appnav .logout {
opacity: 0.7;
}
.appnav .logout:hover {
opacity: 1;
}
@media (min-width: 800px) {
.appnav {
position: static;
margin: 1.5rem 0 0;
border-top: none;
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
border-radius: 12px;
background: color-mix(in srgb, canvas 96%, currentColor 4%);
}
.appnav ul {
padding: 0.4rem 0.5rem;
}
}
</style>

@ -10,6 +10,7 @@
type DatingProfile type DatingProfile
} from '../_lib/types'; } from '../_lib/types';
import ProfileCard from '../_components/ProfileCard.svelte'; import ProfileCard from '../_components/ProfileCard.svelte';
import AppNav from '../_components/AppNav.svelte';
const nexo = getNexoContext(); const nexo = getNexoContext();
@ -239,6 +240,8 @@
</div> </div>
{/if} {/if}
<AppNav />
<style> <style>
.layout { .layout {
display: grid; display: grid;

@ -3,6 +3,7 @@
import { getNexoContext } from '../_lib/context'; import { getNexoContext } from '../_lib/context';
import { DatingApiError } from '../_lib/api'; import { DatingApiError } from '../_lib/api';
import type { DatingMatch, DatingProfile } from '../_lib/types'; import type { DatingMatch, DatingProfile } from '../_lib/types';
import AppNav from '../_components/AppNav.svelte';
const nexo = getNexoContext(); const nexo = getNexoContext();
@ -126,6 +127,8 @@
{/if} {/if}
</section> </section>
<AppNav />
<style> <style>
.wrap { .wrap {
max-width: 36rem; max-width: 36rem;

@ -10,6 +10,7 @@
type DatingVisibility type DatingVisibility
} from '../_lib/types'; } from '../_lib/types';
import Field from '../_components/Field.svelte'; import Field from '../_components/Field.svelte';
import AppNav from '../_components/AppNav.svelte';
const nexo = getNexoContext(); const nexo = getNexoContext();
@ -251,6 +252,8 @@
</form> </form>
</section> </section>
<AppNav />
<style> <style>
.wrap { .wrap {
max-width: 36rem; max-width: 36rem;

Loading…
Cancel
Save

Powered by TurnKey Linux.