From 487f3cf4ff712a46cebe937add7e24b93457e509 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:38:59 +0200 Subject: [PATCH 1/2] fix(nav): "Home" and "Cat" were the same destination, and the dashboard was unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /dashboard's page component router.replace()d every visitor to /dashboard/cat ("Cat-first", 2026-07-13). But /dashboard is what the sidebar's "Home" row, the mobile tab bar, the breadcrumb "Dashboard" crumb, the 404 page's "Go to Dashboard" and every RouteError recovery link point at — so all of them landed on the Cat hub, the destination the "Cat" row already owned. The dashboard only rendered when the timeline fetch *errored*, which is why nobody noticed. /dashboard renders the dashboard again. Cat-first is kept where it belongs: sign-in still lands on CAT_WELCOME (auth/callback + auth/confirm, untouched), and "/" — which the brand mark points at — now redirects to the Cat rather than bouncing through /dashboard. The existing mobile-tab-bar test compared hrefs as strings, so two rows that resolved to one surface through a client redirect read as distinct. New gate follows the redirect: an authenticated nav destination may not redirect to another authenticated nav destination. Proven by mutation — reinstating the router.replace turns it red with "/dashboard → /dashboard/cat". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sh5aTRjzkcZkTyiu9D5RCM --- .../nav-destinations-do-not-bounce.test.ts | 117 ++++++++++++++++++ src/app/(authenticated)/dashboard/page.tsx | 57 ++------- src/app/page.tsx | 6 +- 3 files changed, 129 insertions(+), 51 deletions(-) create mode 100644 __tests__/unit/config/nav-destinations-do-not-bounce.test.ts diff --git a/__tests__/unit/config/nav-destinations-do-not-bounce.test.ts b/__tests__/unit/config/nav-destinations-do-not-bounce.test.ts new file mode 100644 index 000000000..b5e7939dc --- /dev/null +++ b/__tests__/unit/config/nav-destinations-do-not-bounce.test.ts @@ -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)[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([]); + }); +}); diff --git a/src/app/(authenticated)/dashboard/page.tsx b/src/app/(authenticated)/dashboard/page.tsx index ab58a73e6..d28b97ab9 100644 --- a/src/app/(authenticated)/dashboard/page.tsx +++ b/src/app/(authenticated)/dashboard/page.tsx @@ -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, @@ -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( @@ -34,7 +31,6 @@ const DashboardTimeline = dynamic( ); export default function DashboardPage() { - const router = useRouter(); const { user, profile, @@ -45,7 +41,6 @@ export default function DashboardPage() { timelineLoading, timelineError, pendingActions, - pendingActionsLoaded, safeProjects, totalProjects, totalDrafts, @@ -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 ; @@ -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 ; - } - return (
diff --git a/src/app/page.tsx b/src/app/page.tsx index a4f0264f2..2e0e228b2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ; From 437f4418e5d286f5305ac75c489016c2636eec87 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:10:47 +0200 Subject: [PATCH 2/2] fix(profile,nav): a black banner, an anonymous switcher, a misaligned header, and needs published as offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects reported from one pass over a live profile. All of them share a shape: the code says the right thing and the screen shows something else. **1. Every banner and avatar scrim rendered solid black.** Tailwind v4 deleted the `*-opacity-*` utilities. They do not warn — they stop existing, so `bg-black bg-opacity-20` keeps `bg-black` and drops the 20%. The profile banner's scrim became an opaque rectangle over every maker's banner image (the image itself was never the problem: sampling its pixels in the live page gives an average of rgb(182,142,95)), and the avatar's hover scrim in the profile editor became a black square. Rewritten to the v4 slash modifier. A new gate scans src for any surviving `*-opacity-N`, proven by mutation. **2. The sidebar context switcher listed "Audit WF009 …" groups** — throwaway teams minted by workflow audits, which enrol the account they run as. config/public-directory already classifies these and keeps them off Discover and People; the switcher was a second reader that never imported the rule. It does now, so "fixture group" still has exactly one definition. The rows are hidden, not deleted. **3. In that dropdown the user's own row showed a generic person glyph** while every group below it showed its real picture — the one row a person can recognise at a glance was the anonymous one. **4. The profile header read as three unrelated boxes.** The gap between banner and identity card was set in two components that could not see each other (`mb-4 sm:mb-6 lg:mb-8` in ProfileBannerSection, `mt-12 sm:mt-16 md:mt-20` in ProfileLayout) while a third value — the avatar's overhang — decided how much clearance was actually needed. The avatar floated in a band of background belonging to neither box, and its left edge missed the name's by 8px because it used `left-3 sm:left-6 lg:left-8` against the card's `p-4 sm:p-6`. The seam now has one owner: the banner sets no bottom margin, and the identity card carries top padding sized to the overhang, so the avatar overlaps the card the way it overlaps the banner. Overhang is half the avatar at every breakpoint (it was 1/2, 3/5, 2/3 and 1/2), and its inset matches the card's padding. Measured on a running build, logged out: 1280px — avatar l40 b480, card top 432 (48px overlap), h1 l41; previously a 41px dead gap with a 7px offset. scrollWidth 1265/1280. 390px — avatar l28 b232, card top 212 (20px overlap), h1 l29. scrollWidth 375/390 — no horizontal scroll on a phone. **5. "What I can offer" published the user's needs as things they supply.** The extraction prompt defined `asked_for` as "what people come to them for" and stopped there. It runs over a chat with an assistant, where the most common "ask" by far is the user asking the Cat for something — so it recorded their own requests and needs as things they OFFER. A live profile listed "suggestions on what to offer", "helpful assistance", "vet care in Zürich" and "funding for dog surgery" under "People come to me for". The field now states the direction, names the trap, says to leave it empty when in doubt, and the worked example contrasts the two readings. The card also told the owner "Only you see these create shortcuts" — true of the + buttons, and easy to read as covering the entries. It does not: a logged-out visitor sees every skill, asset and asked-for. The footer now says "Visible to everyone", keeps the shortcut hint separate, and links to the Cat, which is the only thing that can correct this data (removeFromEconomicProfile). When the section is empty the owner gets that invitation instead of a component that rendered nothing. The extraction prompt moves to its own module (economic-profile-prompt.ts): it is prose on its own cadence, reviewing a wording change should not mean scrolling past merge/upsert code, and the expanded note pushed the service past the 500-line limit. Verified with `npm run verify`. Worth knowing: `core.hooksPath` is the relative `.husky/_`, which is gitignored and generated by npm install, so it exists only in the main checkout — inside a worktree git finds no pre-commit hook and a commit prints nothing but the commit line. The size failure above was caught only because verify was run by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sh5aTRjzkcZkTyiu9D5RCM --- .../config/tailwind-v4-dead-utilities.test.ts | 53 ++++++++++++ ...gationContext-hides-fixture-groups.test.ts | 56 ++++++++++++ .../profile/ProfileBannerSection.tsx | 16 +++- .../profile/ProfileImagesSection.tsx | 2 +- src/components/profile/ProfileLayout.tsx | 8 +- src/components/profile/ProfileOfferings.tsx | 86 +++++++++++++++---- src/components/profile/ProfileSkeleton.tsx | 21 +++-- src/components/sidebar/ContextSwitcher.tsx | 20 ++++- src/components/ui/UserProfileDropdown.tsx | 2 +- src/hooks/useNavigationContext.ts | 14 ++- src/services/cat/economic-profile-prompt.ts | 40 +++++++++ src/services/cat/economic-profile.ts | 15 +--- 12 files changed, 283 insertions(+), 50 deletions(-) create mode 100644 __tests__/unit/config/tailwind-v4-dead-utilities.test.ts create mode 100644 __tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts create mode 100644 src/services/cat/economic-profile-prompt.ts diff --git a/__tests__/unit/config/tailwind-v4-dead-utilities.test.ts b/__tests__/unit/config/tailwind-v4-dead-utilities.test.ts new file mode 100644 index 000000000..2b1aa3d52 --- /dev/null +++ b/__tests__/unit/config/tailwind-v4-dead-utilities.test.ts @@ -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([]); + }); +}); diff --git a/__tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts b/__tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts new file mode 100644 index 000000000..b44d94071 --- /dev/null +++ b/__tests__/unit/hooks/useNavigationContext-hides-fixture-groups.test.ts @@ -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); + }); +}); diff --git a/src/components/profile/ProfileBannerSection.tsx b/src/components/profile/ProfileBannerSection.tsx index c1f565a0f..cb7a8fbee 100644 --- a/src/components/profile/ProfileBannerSection.tsx +++ b/src/components/profile/ProfileBannerSection.tsx @@ -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 ( -
+
{/* 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. */} @@ -48,12 +54,16 @@ export function ProfileBannerSection({ className="object-cover" /> )} -
+
{/* Avatar */} -
+ {/* 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. */} +
{profile.avatar_url ? (
)} -
+
diff --git a/src/components/profile/ProfileLayout.tsx b/src/components/profile/ProfileLayout.tsx index d11a26aa0..94913cfaa 100644 --- a/src/components/profile/ProfileLayout.tsx +++ b/src/components/profile/ProfileLayout.tsx @@ -228,8 +228,12 @@ export default function ProfileLayout({ onFollowToggle={handleFollowToggle} /> -
-
+
+ {/* 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. */} +

{profile.name || profile.username || 'User'}

diff --git a/src/components/profile/ProfileOfferings.tsx b/src/components/profile/ProfileOfferings.tsx index 78260e61f..c70e94b26 100644 --- a/src/components/profile/ProfileOfferings.tsx +++ b/src/components/profile/ProfileOfferings.tsx @@ -15,8 +15,9 @@ */ import Link from 'next/link'; -import { Sparkles, Plus, MinusCircle } from 'lucide-react'; +import { Sparkles, Plus, MinusCircle, Globe, MessageCircle } from 'lucide-react'; import { ENTITY_REGISTRY } from '@/config/entity-registry'; +import { ROUTES } from '@/config/routes'; import { suggestedEntityForSkill, type PublicEconomicProfile, @@ -27,21 +28,64 @@ interface ProfileOfferingsProps { isOwnProfile?: boolean; } +/** Opens the Cat with the question already typed. */ +function askCatHref(question: string): string { + return `${ROUTES.DASHBOARD.CAT}?q=${encodeURIComponent(question)}`; +} + +const DISCOVER_QUESTION = + "Help me work out what I can offer. Ask me what I'm good at, what I own that could earn, " + + 'and what other people come to me for — then put it on my profile.'; + +// The Cat can remove entries as well as add them (removeFromEconomicProfile), +// which is the only way to take something off this card. Say so, because a +// wrong entry here is wrong in public. +const CORRECT_QUESTION = + 'Go through "What I can offer" on my profile with me. Remove anything that is not right ' + + "and help me add what's missing."; + +function AskCatLink({ label, question }: { label: string; question: string }) { + return ( + + + {label} + + ); +} + export default function ProfileOfferings({ economicProfile, isOwnProfile }: ProfileOfferingsProps) { - if (!economicProfile) { - return null; - } - const skills = economicProfile.skills ?? []; - const askedFor = economicProfile.askedFor ?? []; - const assets = economicProfile.assets ?? []; - const notAvailableFor = economicProfile.notAvailableFor ?? []; - if ( + const skills = economicProfile?.skills ?? []; + const askedFor = economicProfile?.askedFor ?? []; + const assets = economicProfile?.assets ?? []; + const notAvailableFor = economicProfile?.notAvailableFor ?? []; + const isEmpty = skills.length === 0 && askedFor.length === 0 && assets.length === 0 && - notAvailableFor.length === 0 - ) { - return null; + notAvailableFor.length === 0; + + if (isEmpty) { + // A visitor sees nothing. The owner sees the way to fill it — an empty + // section is the one place the invitation is most useful, and it used to + // be the one place that rendered nothing at all. + if (!isOwnProfile) { + return null; + } + return ( +
+
+ +

What I can offer

+
+

+ Nothing here yet. This is the part of your profile a potential client actually reads. +

+ +
+ ); } return ( @@ -146,10 +190,20 @@ export default function ProfileOfferings({ economicProfile, isOwnProfile }: Prof
)} - {isOwnProfile && skills.length > 0 && ( -

- Tap a skill to turn it into a listing. Only you see these create shortcuts. -

+ {isOwnProfile && ( +
+ {/* "Only you see these create shortcuts" was true of the +buttons and + easy to read as though it covered the entries themselves. It does + not: everything above is on the public profile. Say which is which. */} +

+ + + Visible to everyone. + {skills.length > 0 && ' The + shortcuts are yours alone — tap one to list it.'} + +

+ +
)}
); diff --git a/src/components/profile/ProfileSkeleton.tsx b/src/components/profile/ProfileSkeleton.tsx index 2b412652b..dfe0a8ecb 100644 --- a/src/components/profile/ProfileSkeleton.tsx +++ b/src/components/profile/ProfileSkeleton.tsx @@ -89,21 +89,28 @@ export function ProjectsSkeleton() { export function ProfilePageSkeleton() { return (
-
+ {/* Every measurement below mirrors ProfileBannerSection / ProfileLayout. + It used to guess: a 192px banner where the real one is 128px on a + phone, a 96px avatar where the real one is 64px, a different inset and + a different overhang. So the header visibly jumped — banner shrank, + portrait slid — the moment real content replaced the skeleton, which + is half of why the area read as thrown together. A skeleton that does + not match is worse than no skeleton. */} +
{/* Header Banner Skeleton */} -
-
-
-
+
+
+
+
-
+
{/* Main Content Skeleton */} -
+
{/* Basic Info Card Skeleton */}
diff --git a/src/components/sidebar/ContextSwitcher.tsx b/src/components/sidebar/ContextSwitcher.tsx index 4167e5e3f..fbe20b97d 100644 --- a/src/components/sidebar/ContextSwitcher.tsx +++ b/src/components/sidebar/ContextSwitcher.tsx @@ -188,8 +188,24 @@ export function ContextSwitcher({ !isGroupContext && 'bg-surface-raised' )} > -
- + {/* Your own row showed a generic person glyph while every group + below it showed its real picture — the one row a person can + identify at a glance was the one rendered anonymously. */} +
+ {profile.avatar_url ? ( + {profile.name + ) : ( +
+ +
+ )}

diff --git a/src/components/ui/UserProfileDropdown.tsx b/src/components/ui/UserProfileDropdown.tsx index a5b839d30..f60493194 100644 --- a/src/components/ui/UserProfileDropdown.tsx +++ b/src/components/ui/UserProfileDropdown.tsx @@ -186,7 +186,7 @@ export default function UserProfileDropdown({ {isOpen && ( -

+
{menuItems.map(item => { const Icon = item.icon; diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts index 190dc98f4..344dada1b 100644 --- a/src/hooks/useNavigationContext.ts +++ b/src/hooks/useNavigationContext.ts @@ -18,6 +18,7 @@ import { usePathname } from 'next/navigation'; import { useAuth } from './useAuth'; import { logger } from '@/utils/logger'; import { API_ROUTES } from '@/config/api-routes'; +import { isFixtureGroupTitle } from '@/config/public-directory'; type NavigationContextType = 'individual' | 'group'; @@ -116,14 +117,19 @@ export function useNavigationContext(): UseNavigationContextReturn { } const data = await response.json(); if (!cancelled && data.success && data.data?.groups) { - const groups: GroupContextInfo[] = data.data.groups.map( - (g: { id: string; slug: string; name: string; avatar_url?: string }) => ({ + const groups: GroupContextInfo[] = data.data.groups + // CI and workflow audits mint throwaway groups ("Audit WF009 …") + // and add the account they run as to them. Public directories + // already hide those rows; the switcher never got the same filter, + // so they sat in the user's own context menu as if they were real + // teams. Same predicate, one definition — see config/public-directory. + .filter((g: { name: string }) => !isFixtureGroupTitle(g.name)) + .map((g: { id: string; slug: string; name: string; avatar_url?: string }) => ({ id: g.id, slug: g.slug, name: g.name, avatar_url: g.avatar_url, - }) - ); + })); setUserGroups(groups); } } catch (error) { diff --git a/src/services/cat/economic-profile-prompt.ts b/src/services/cat/economic-profile-prompt.ts new file mode 100644 index 000000000..d79998dd9 --- /dev/null +++ b/src/services/cat/economic-profile-prompt.ts @@ -0,0 +1,40 @@ +/** + * The system prompt for passive economic extraction. + * + * It lives in its own module because it is prose, not logic: it changes for + * editorial reasons, on its own cadence, and reviewing a wording change should + * not mean scrolling past the merge/upsert code that happens to call it. + * + * `asked_for` carries the longest note for a reason. It used to read simply + * "what people come to them for", and the extractor runs over a chat with an + * assistant — where the most frequent ask by far is the user asking the Cat + * for something. So it recorded people's own requests and needs as things they + * SUPPLY, and the profile published them: a live account listed "vet care in + * Zurich" and "funding for dog surgery" under "People come to me for". + */ + +export const ECON_EXTRACTION_SYSTEM = `You extract a person's LATENT ECONOMIC VALUE from one chat exchange — only what they actually stated or clearly implied, never invented. + +Pull, where present: +- skills: things they can do (names). Treat self-deprecation ("it's nothing", "just a hobby", "anyone can do that") as a real skill worth capturing. +- assets: things they OWN that could be rented or sold. +- goals: what they want; each {text, kind} where kind is earn | fund | learn | connect | build. +- constraints: PRIVATE limits like "only evenings", "no upfront capital" — never shown publicly. +- asked_for: what OTHER PEOPLE come to THIS PERSON for — help they are sought out to give. + This exchange is a chat with an assistant, so the most frequent "ask" in it is the + person asking YOU for something. That is the opposite of this field. Never record what + they requested from you, and never record a need of their own ("funding for surgery", + "a vet in Zurich", "suggestions on what to offer") — those are things they WANT, and + this field is published on their public profile as something they SUPPLY. Capture it + only from a statement about other people coming to them, e.g. "friends always ask me + to fix their bikes". If in doubt, leave it empty. +- not_available_for: PUBLIC scope limits they'd want a prospective client/collaborator to see up front — e.g. "not taking full-time roles", "advisory only, no hands-on coding", "nothing under 3 months". Distinct from constraints: only capture this when they're describing what kind of engagement they will or won't take, not private life constraints. +- motivation: why they're here — earn | community | meaning | learn | unsure. +- stage: exploring | has-offers | scaling. + +Rules: ground everything in THIS exchange; omit anything not stated; never infer demand, prices, or stats. Output ONLY a JSON object with those keys (arrays empty if none), nothing else. Example: +{"skills":["translation"],"assets":[],"goals":[{"text":"earn on the side","kind":"earn"}],"constraints":[],"asked_for":["writing clear emails"],"not_available_for":[],"motivation":"earn","stage":null} + +That asked_for came from "colleagues keep asking me to clean up their emails" — a statement +about other people seeking them out. Had they instead asked you "can you help me write a +clearer email?", asked_for would be [].`; diff --git a/src/services/cat/economic-profile.ts b/src/services/cat/economic-profile.ts index 18348cc7d..a19d709a8 100644 --- a/src/services/cat/economic-profile.ts +++ b/src/services/cat/economic-profile.ts @@ -16,6 +16,7 @@ import type { AnySupabaseClient } from '@/lib/supabase/types'; import { DATABASE_TABLES } from '@/config/database-tables'; import { logger } from '@/utils/logger'; import { looksLikeSelfDisclosure, selectForgetFacts, type MemoryAiService } from './memory'; +import { ECON_EXTRACTION_SYSTEM } from './economic-profile-prompt'; export interface EconomicSkill { name: string; @@ -435,20 +436,6 @@ export function normalizeEconomicPatch( return hasAny ? patch : null; } -const ECON_EXTRACTION_SYSTEM = `You extract a person's LATENT ECONOMIC VALUE from one chat exchange — only what they actually stated or clearly implied, never invented. - -Pull, where present: -- skills: things they can do (names). Treat self-deprecation ("it's nothing", "just a hobby", "anyone can do that") as a real skill worth capturing. -- assets: things they OWN that could be rented or sold. -- goals: what they want; each {text, kind} where kind is earn | fund | learn | connect | build. -- constraints: PRIVATE limits like "only evenings", "no upfront capital" — never shown publicly. -- asked_for: what people come to them for. -- not_available_for: PUBLIC scope limits they'd want a prospective client/collaborator to see up front — e.g. "not taking full-time roles", "advisory only, no hands-on coding", "nothing under 3 months". Distinct from constraints: only capture this when they're describing what kind of engagement they will or won't take, not private life constraints. -- motivation: why they're here — earn | community | meaning | learn | unsure. -- stage: exploring | has-offers | scaling. - -Rules: ground everything in THIS exchange; omit anything not stated; never infer demand, prices, or stats. Output ONLY a JSON object with those keys (arrays empty if none), nothing else. Example: -{"skills":["translation"],"assets":[],"goals":[{"text":"earn on the side","kind":"earn"}],"constraints":[],"asked_for":["writing clear emails"],"not_available_for":[],"motivation":"earn","stage":null}`; /** * Passive, deterministic economic extraction — runs after each self-disclosing turn