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:
@@ -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'}"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user