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} />

View File

@@ -1,12 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { isAuthenticated, authLoading, currentUser } from '$lib/stores/auth';
import { login, isLoggingOut } from '$lib/auth';
import { getSupabase } from '$lib/supabase';
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
import { currentCollective } from '$lib/stores/collective';
import { enabledSections } from '$lib/stores/features';
import type { SectionKey } from '@colectivo/types';
import { setSyncContext } from '$lib/sync';
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
@@ -57,6 +60,35 @@
});
});
// Fase 12.3.2: section-visibility route guard. If the URL hits a
// disabled section we bounce to /lists and surface a transient toast.
// "lists" is hard-allowed (the §12.3.4 always-one-landing rule) even if
// every flag layer says OFF — its $enabledSections value is still
// computed normally but we never redirect away from it from here.
const SECTION_BY_PREFIX: Array<[string, SectionKey]> = [
['/tasks', 'tasks'],
['/notes', 'notes'],
['/search', 'search']
];
let sectionToast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if ($authLoading || !$isAuthenticated) return;
const path = $page.url.pathname;
const match = SECTION_BY_PREFIX.find(
([prefix]) => path === prefix || path.startsWith(`${prefix}/`)
);
if (!match) return;
const [, section] = match;
if ($enabledSections[section]) return;
// Disabled — show toast then redirect away.
sectionToast = m.section_disabled_for_collective();
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (sectionToast = null), 3500);
void goto('/lists');
});
// Fase 9.1: cross-device theme sync. When the user logs in, read
// public.users.theme and adopt it if it differs from the local
// preference. The anti-FOUC inline script in app.html and initTheme()
@@ -117,5 +149,17 @@
<BottomTabBar />
<UndoToast />
{#if sectionToast}
<div
data-testid="section-disabled-toast"
role="status"
aria-live="polite"
class="pointer-events-none fixed inset-x-0 top-6 z-50 flex justify-center px-4"
>
<div class="pointer-events-auto rounded-md bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.25)] dark:bg-slate-100 dark:text-slate-900">
{sectionToast}
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -25,6 +25,14 @@
// avoid FOUC — initTheme just builds the JS-side state on top.
initTheme();
// Dev-only: expose the supabase client on window so Playwright tests
// can drive PostgREST through it without re-creating a parallel
// client. Production never sees this (import.meta.env.DEV is
// dead-code-eliminated by Vite at build time).
if (import.meta.env.DEV) {
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
}
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
// not auto-register — the virtual module is a no-op unless called.
void import('virtual:pwa-register').then(({ registerSW }) => {