feat(fase-12): nav gating + redirect guard + realtime publication

UI side:
  * DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
    tag each entry with data-section, and BottomTabBar exposes a
    data-section-count attribute so the grid is observable from tests.
  * "lists" is force-shown unconditionally — the §12.3.4 always-one-
    landing rule covers the edge case where every layer says OFF.
  * (app)/+layout.svelte adds a $effect that, when the URL matches a
    disabled section (/tasks, /notes, /search), goto's /lists and shows
    a transient `section_disabled_for_collective` toast.
  * Root +layout.svelte exposes the supabase singleton on window.__sb
    in dev so the new Playwright spec can patch rows without a parallel
    client; dead-code-eliminated in production builds.

DB side:
  * Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
    public.users + public.collectives + REPLICA IDENTITY FULL on both.
    Without this membership the features.ts subscriptions get zero
    events and SV-02 (collective toggle → realtime → member's nav
    updates) silently fails. Caught while running the spec.
  * pgTAP 015 grows from 16 to 20 assertions to cover publication
    membership + REPLICA IDENTITY for both new realtime tables.

Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.

Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
  SV-01 user toggle → nav reflects → /tasks redirects to /lists
  SV-02 admin collective toggle → member sees it disappear in realtime
  SV-03 collective ON beats user OFF — per-collective resolution

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 04:10:19 +02:00
parent 59c425f6f6
commit 286f59225f
9 changed files with 369 additions and 15 deletions

View File

@@ -1,15 +1,26 @@
<script lang="ts">
import { page } from '$app/stores';
import { enabledSections } from '$lib/stores/features';
import type { SectionKey } from '@colectivo/types';
import * as m from '$lib/paraglide/messages';
import { ShoppingCart, CheckSquare, FileText, Search } from 'lucide-svelte';
const items = [
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
{ href: '/search', label: () => m.nav_search(), icon: Search }
const allItems: Array<{ section: SectionKey; href: string; label: () => string; icon: typeof ShoppingCart }> = [
{ section: 'lists', href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
{ section: 'tasks', href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
{ section: 'notes', href: '/notes', label: () => m.nav_notes(), icon: FileText },
{ section: 'search', href: '/search', label: () => m.nav_search(), icon: Search }
];
// Fase 12.3.1/12.3.3: filter by enabled flags + recompute the grid column
// count from the visible length so the tab-bar adapts (the original was
// `justify-around` over a fixed 4; we keep that distribution but the
// number of children is now dynamic). "lists" is always shown
// (§12.3.4 hard override).
const items = $derived(
allItems.filter((it) => it.section === 'lists' || $enabledSections[it.section])
);
const activePath = $derived($page.url.pathname);
function isActive(href: string): boolean {
@@ -19,14 +30,16 @@
<nav
data-testid="bottom-tab-bar"
data-section-count={items.length}
class="md:hidden fixed inset-x-0 bottom-0 z-40 border-t border-black/5 bg-surface/80 backdrop-blur-xl pb-[env(safe-area-inset-bottom)] dark:border-white/5"
>
<ul class="flex items-stretch justify-around">
{#each items as { href, label, icon: Icon }}
{#each items as { section, href, label, icon: Icon } (section)}
{@const active = isActive(href)}
<li class="flex-1">
<a
{href}
data-section={section}
aria-current={active ? 'page' : undefined}
aria-label={label()}
class="flex h-14 flex-col items-center justify-center gap-0.5 text-xs transition-colors {active ? 'text-slate-900 dark:text-slate-50' : 'text-slate-400 dark:text-slate-500'}"

View File

@@ -2,18 +2,29 @@
import { page } from '$app/stores';
import { displayName } from '$lib/stores/auth';
import { currentCollective, userCollectives } from '$lib/stores/collective';
import { enabledSections } from '$lib/stores/features';
import type { SectionKey } from '@colectivo/types';
import Avatar from '$lib/components/Avatar.svelte';
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
import * as m from '$lib/paraglide/messages';
import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus } from 'lucide-svelte';
const navItems = [
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
{ href: '/search', label: () => m.nav_search(), icon: Search }
// Fase 12.3.1: nav items declared with their section key so we can filter
// by $enabledSections (precedence resolved client-side; see features.ts).
// Force "lists" through unconditionally in case every flag layer says OFF
// — matches the BottomTabBar / MobileDrawer "always one safe landing"
// rule documented in §12.3.4.
const allNavItems: Array<{ section: SectionKey; href: string; label: () => string; icon: typeof ShoppingCart }> = [
{ section: 'lists', href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
{ section: 'tasks', href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
{ section: 'notes', href: '/notes', label: () => m.nav_notes(), icon: FileText },
{ section: 'search', href: '/search', label: () => m.nav_search(), icon: Search }
];
const navItems = $derived(
allNavItems.filter((item) => item.section === 'lists' || $enabledSections[item.section])
);
let switcherOpen = $state(false);
let showCreate = $state(false);
@@ -76,9 +87,10 @@
</div>
<nav class="flex-1 overflow-y-auto px-2 py-3">
{#each navItems as { href, label, icon: Icon }}
{#each navItems as { section, href, label, icon: Icon } (section)}
<a
{href}
data-section={section}
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath.startsWith(href) ? 'bg-black/10 text-slate-900 dark:bg-white/10 dark:text-slate-50' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
>
<Icon size={16} strokeWidth={1.5} />