Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions __tests__/unit/config/nav-destinations-do-not-bounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* A nav entry is a promise: "this row takes you somewhere the other rows don't."
*
* `mobile-tab-bar.test.ts` already checks that no two rows share an href — but
* hrefs are compared as *strings*, and that is exactly how the sidebar shipped
* two rows ("Cat" and "Home") that both landed on /dashboard/cat: /dashboard's
* page component `router.replace`d everyone to the Cat hub. Textually distinct,
* identical in the browser, and the dashboard became unreachable — along with
* the breadcrumb crumb, the 404 recovery link and every RouteError that points
* at /dashboard.
*
* So: an authenticated nav destination may not redirect to another authenticated
* nav destination. Bouncing somewhere that is NOT a nav row (/auth, onboarding)
* stays allowed — that's a guard, not a duplicate row.
*
* Scope, stated rather than implied: this reads each destination's own
* `page.tsx`. A redirect hidden inside a child component or a layout is not
* caught here.
*/

import { existsSync, readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { mobileTabBar, sidebarSections } from '@/config/navigation';
import { ROUTES } from '@/config/routes';

const APP_DIR = join(process.cwd(), 'src', 'app');

/** Every route the signed-in user can reach from a nav row, path-only. */
function authenticatedNavDestinations(): string[] {
const fromSidebar = sidebarSections
.flatMap(section => section.items)
.filter(item => item.requiresAuth !== false)
.map(item => item.href);
const fromTabBar = mobileTabBar.filter(item => !item.opensCreate).map(item => item.href);
return [...new Set([...fromSidebar, ...fromTabBar])]
.filter((href): href is string => typeof href === 'string' && href.startsWith('/'))
.map(stripQuery);
}

function stripQuery(href: string): string {
return href.split(/[?#]/)[0];
}

/** Resolve "/dashboard" to its page file, looking through route groups. */
function pageFileFor(href: string): string | null {
const segments = href.split('/').filter(Boolean);
const groups = readdirSync(APP_DIR, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith('('))
.map(entry => entry.name);
for (const prefix of ['', ...groups]) {
const candidate = join(APP_DIR, prefix, ...segments, 'page.tsx');
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}

/** Look up "ROUTES.DASHBOARD.CAT" against the real config. */
function resolveRoutesExpression(expression: string): string | null {
const path = expression.split('.').slice(1);
let value: unknown = ROUTES;
for (const key of path) {
if (typeof value !== 'object' || value === null || !(key in value)) {
return null;
}
value = (value as Record<string, unknown>)[key];
}
return typeof value === 'string' ? value : null;
}

/** Redirect targets a page sends the user to on arrival. */
function redirectTargetsIn(source: string): string[] {
const targets: string[] = [];
const calls = source.matchAll(/(?:router\.replace|\bredirect)\(([^)]*)\)/g);
for (const [, argument] of calls) {
for (const [expression] of argument.matchAll(/ROUTES(?:\.[A-Z0-9_]+)+/g)) {
const resolved = resolveRoutesExpression(expression);
if (resolved) {
targets.push(stripQuery(resolved));
}
}
for (const [, literal] of argument.matchAll(/['"](\/[^'"]*)['"]/g)) {
targets.push(stripQuery(literal));
}
}
return targets;
}

describe('authenticated nav destinations', () => {
const destinations = authenticatedNavDestinations();

it('finds the nav rows it is meant to police', () => {
expect(destinations).toContain(ROUTES.DASHBOARD.HOME);
expect(destinations).toContain(ROUTES.DASHBOARD.CAT);
});

it('never redirects one nav row onto another', () => {
const others = new Set(destinations);
const collisions: string[] = [];

for (const href of destinations) {
const file = pageFileFor(href);
if (!file) {
continue; // dynamic or externally-served route — nothing to read
}
const source = readFileSync(file, 'utf8');
for (const target of redirectTargetsIn(source)) {
if (target !== href && others.has(target)) {
collisions.push(`${href} → ${target}`);
}
}
}

expect(collisions).toEqual([]);
});
});
53 changes: 53 additions & 0 deletions __tests__/unit/config/tailwind-v4-dead-utilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Tailwind v4 deleted the `*-opacity-*` utilities. They don't warn — they
* simply don't exist, so `bg-black bg-opacity-20` keeps the `bg-black` and
* drops the 20%: a solid black rectangle where a subtle scrim was intended.
*
* That is what turned the profile banner black on every maker profile, and
* what made the avatar's hover scrim in the profile editor an opaque black
* square instead of a 30% dim. Both had been that way since the v4 upgrade,
* and neither the type checker nor the linter can see inside a class string.
*
* The v4 spelling is the slash modifier: `bg-black/20`, `ring-black/5`.
*/

import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';

const SRC = join(process.cwd(), 'src');

/** `bg-opacity-20`, `hover:ring-opacity-5`, `dark:text-opacity-100`, … */
const DEAD_UTILITY =
/(?:^|[\s"'`:])((?:[a-z-]+:)*(?:bg|text|border|ring|divide|placeholder|from|via|to)-opacity-\d+)/g;

function sourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
return sourceFiles(full);
}
return /\.(tsx?|css)$/.test(entry.name) ? [full] : [];
});
}

describe('Tailwind v4', () => {
it('is actually looking at the source tree it claims to scan', () => {
expect(statSync(SRC).isDirectory()).toBe(true);
expect(sourceFiles(SRC).length).toBeGreaterThan(100);
});

it('has no *-opacity-* utilities left — v4 dropped them silently', () => {
const offenders: string[] = [];

for (const file of sourceFiles(SRC)) {
const source = readFileSync(file, 'utf8');
source.split('\n').forEach((line, index) => {
for (const [, utility] of line.matchAll(DEAD_UTILITY)) {
offenders.push(`${file.replace(process.cwd() + '/', '')}:${index + 1} ${utility}`);
}
});
}

expect(offenders).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* CI and workflow audits mint disposable groups ("Audit WF009 1783192620260")
* and enrol the account they run as. `config/public-directory` already knows
* they aren't real teams and keeps them off Discover and People — but the
* sidebar context switcher fetched `?membership=mine` and rendered whatever
* came back, so a live account's own context menu listed two audit fixtures
* alongside "Personal".
*
* The filter has one definition. This test holds the switcher to it.
*/

import { renderHook, waitFor } from '@testing-library/react';
import { useNavigationContext } from '@/hooks/useNavigationContext';

const mockStableAuth = { user: { id: 'user-1' } };
jest.mock('@/hooks/useAuth', () => ({
useAuth: () => mockStableAuth,
}));
jest.mock('next/navigation', () => ({
usePathname: () => '/dashboard',
}));

const GROUPS = [
{ id: 'g1', slug: 'zurich-makers', name: 'Zürich Makers', avatar_url: null },
{ id: 'g2', slug: 'audit-wf009-1783192171', name: 'Audit WF009 1783192171', avatar_url: null },
{ id: 'g3', slug: 'audit-wf009b-1783192620260', name: 'Audit WF009b 1783192620260' },
{ id: 'g4', slug: 'ephemeral-verify-1', name: 'Ephemeral Verify 1' },
];

beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: async () => ({ success: true, data: { groups: GROUPS } }),
})
) as unknown as typeof fetch;
window.localStorage.clear();
});

describe('context switcher group list', () => {
it('lists only real groups — audit fixtures are not teams the user joined', async () => {
const { result } = renderHook(() => useNavigationContext());

await waitFor(() => expect(result.current.loadingGroups).toBe(false));

expect(result.current.userGroups.map(g => g.slug)).toEqual(['zurich-makers']);
});

it('would have shown the fixtures without the filter — the fetch really returns them', async () => {
renderHook(() => useNavigationContext());

await waitFor(() => expect(global.fetch).toHaveBeenCalled());
const returned = GROUPS.filter(g => g.name.startsWith('Audit WF'));
expect(returned).toHaveLength(2);
});
});
57 changes: 7 additions & 50 deletions src/app/(authenticated)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
'use client';

import dynamic from 'next/dynamic';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Loading from '@/components/Loading';
import {
DashboardHeader,
Expand All @@ -14,7 +12,6 @@ import {
import { MobileDashboardSidebar } from '@/components/dashboard/MobileDashboardSidebar';
import { CatNudges } from '@/components/dashboard/CatNudges';
import { PendingActionsCard } from '@/components/ai-chat/PendingActionsCard';
import { ROUTES } from '@/config/routes';
import { useDashboard } from './useDashboard';

const DashboardTimeline = dynamic(
Expand All @@ -34,7 +31,6 @@ const DashboardTimeline = dynamic(
);

export default function DashboardPage() {
const router = useRouter();
const {
user,
profile,
Expand All @@ -45,7 +41,6 @@ export default function DashboardPage() {
timelineLoading,
timelineError,
pendingActions,
pendingActionsLoaded,
safeProjects,
totalProjects,
totalDrafts,
Expand All @@ -56,45 +51,13 @@ export default function DashboardPage() {
handleRejectAction,
} = useDashboard();

// Cat-first (2026-07-13, one-week plan C-1): the Cat is the DEFAULT surface.
// Everyone landing on /dashboard is routed to their Cat hub — brand-new
// accounts get the welcome variant, everyone else the plain hub. Dashboard
// content stays reachable via the left nav (Timeline → ROUTES.TIMELINE,
// entities via their own routes).
//
// Exception: if the timeline fetch errored we do NOT redirect — we render the
// dashboard below so the user keeps a visible error + retry path instead of
// being bounced away from a transient failure. (timelineFeed stays null on
// error, which would otherwise look identical to "empty".)
const isTrulyEmpty =
hydrated &&
!localLoading &&
!timelineLoading &&
!timelineError &&
pendingActionsLoaded &&
!hasProjects &&
pendingActions.length === 0 &&
(timelineFeed?.events?.length ?? 0) === 0;

useEffect(() => {
if (!hydrated || localLoading || !user || timelineError) {
return;
}
if (isTrulyEmpty) {
router.replace(ROUTES.DASHBOARD.CAT_WELCOME);
} else if (!timelineLoading && pendingActionsLoaded) {
router.replace(ROUTES.DASHBOARD.CAT);
}
}, [
hydrated,
localLoading,
user,
isTrulyEmpty,
timelineLoading,
pendingActionsLoaded,
timelineError,
router,
]);
// /dashboard renders the dashboard. It used to router.replace() everyone to
// the Cat hub ("Cat-first", 2026-07-13), which made the surface unreachable:
// the sidebar, the mobile tab bar, the breadcrumb "Dashboard" crumb, the 404
// page and every RouteError recovery link all point here, so all of them
// silently landed on /dashboard/cat — the destination the "Cat" nav item
// already owned. Cat-first is still honoured where it belongs: sign-in goes
// to CAT_WELCOME (auth/callback + auth/confirm) and "/" redirects to the Cat.

if (!hydrated || localLoading) {
return <Loading fullScreen message="Loading your account..." />;
Expand All @@ -108,12 +71,6 @@ export default function DashboardPage() {
return null;
}

// Cat-first: show the redirect loader for everyone except the timeline-error
// fallback (which renders the dashboard below so the user can retry).
if (!timelineError) {
return <Loading fullScreen message="Taking you to Cat..." />;
}

return (
<div className="oc-page">
<div className="oc-page-container oc-page-stack pb-20 sm:pb-8">
Expand Down
6 changes: 5 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ export default async function Home() {
redirect(ROUTES.ONBOARDING.INTELLIGENT);
}
}
redirect(ROUTES.DASHBOARD.HOME);
// Cat-first: an already-onboarded visitor landing on "/" (or the brand
// mark, which points here) goes to the Cat hub. Deliberately NOT
// DASHBOARD.HOME — /dashboard is the dashboard again, and the sidebar's
// "Home" entry is what takes you there.
redirect(ROUTES.DASHBOARD.CAT);
}

return <HomePublic />;
Expand Down
16 changes: 13 additions & 3 deletions src/components/profile/ProfileBannerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ export function ProfileBannerSection({
onShareToggle,
onFollowToggle,
}: ProfileBannerSectionProps) {
// The gap under the banner is NOT set here. The avatar hangs past the
// banner's bottom edge, so whatever clears it has to know the overhang — and
// that is the identity card in ProfileLayout, which the avatar now overlaps.
// A margin here as well would be a second, blind opinion about the same seam,
// which is how the avatar ended up floating in a band of background that
// belonged to neither box.
return (
<div className="relative mb-4 sm:mb-6 lg:mb-8">
<div className="relative">
{/* Banner — monochrome neutral default per migration 6/N; user
banner_url image overlays on top. The dark-bottom overlay stays
so action buttons remain readable. */}
Expand All @@ -48,12 +54,16 @@ export function ProfileBannerSection({
className="object-cover"
/>
)}
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
<div className="absolute inset-0 bg-black/20"></div>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent"></div>
</div>

{/* Avatar */}
<div className="absolute -bottom-8 sm:-bottom-12 md:-bottom-16 left-3 sm:left-6 lg:left-8">
{/* Overhang is half the avatar at every breakpoint (it was 1/2, 3/5, 2/3
and 1/2 before), and the horizontal inset matches the identity card's
own padding (p-4 sm:p-6) so the avatar and the name share one left
edge instead of missing it by 8px. */}
<div className="absolute z-10 -bottom-8 sm:-bottom-10 md:-bottom-12 lg:-bottom-16 left-4 sm:left-6">
{profile.avatar_url ? (
<Image
src={profile.avatar_url}
Expand Down
2 changes: 1 addition & 1 deletion src/components/profile/ProfileImagesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function ProfileImagesSection({
<User className="w-8 h-8 text-fg-tertiary" data-testid="camera-icon" />
</div>
)}
<div className="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-30 flex items-center justify-center rounded-full">
<div className="absolute inset-0 bg-black/0 hover:bg-black/30 flex items-center justify-center rounded-full">
<Camera className="w-5 h-5 text-white opacity-0 hover:opacity-100" />
</div>
</div>
Expand Down
8 changes: 6 additions & 2 deletions src/components/profile/ProfileLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,12 @@ export default function ProfileLayout({
onFollowToggle={handleFollowToggle}
/>

<div className="mt-12 sm:mt-16 md:mt-20">
<div className="oc-surface mb-4 p-4 sm:mb-6 sm:p-6">
<div className="mt-3 sm:mt-4">
{/* Top padding clears the avatar's overhang (half its height) plus a
gap, so the avatar overlaps this card the way it overlaps the
banner — one continuous header rather than two boxes with a
portrait stranded in the white space between them. */}
<div className="oc-surface mb-4 p-4 pt-12 sm:mb-6 sm:p-6 sm:pt-14 md:pt-16 lg:pt-20">
<h1 className="mb-1 break-words text-xl font-bold text-fg-primary sm:mb-2 sm:text-2xl md:text-3xl">
{profile.name || profile.username || 'User'}
</h1>
Expand Down
Loading
Loading