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
19 changes: 17 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,27 @@ vote binds.

This design has **no account menu at all** — identity is a monogram and sign-out
is a top-bar button — so `02-login` and `07-account-menu` were re-specified
rather than repaired. Nav IA is one meaning per entry: Dashboard (yours),
Hackathons (all, searchable), About; the wordmark goes home for everyone.
rather than repaired. Nav IA is one meaning per entry: Dashboard (yours) and
**All Hackathons** (all, searchable); the wordmark goes home for everyone.
"Hackathons" used to resolve to the dashboard when signed in and the browse page
when not, so the same word meant two things and the browse page was unreachable
from the chrome for exactly the people with an account.

**Two distinct labels were not enough** (fixed 2026-08-13). Pointing the two
entries at two destinations stopped them fighting, but "Dashboard" and
"Hackathons" are both nouns for a list of hackathons and neither word says
*whose* — and the bar's own wordmark reads "Hackathons" too, so the noun appeared
twice, once as the platform and once as a page inside it. The pair is separated
three ways now: the label states the scope (`All Hackathons`), an icon carries it
pre-reading (`layout-dashboard` vs `compass`), and a one-line hint spells it out —
as a `title` on the bar, as visible text in the mobile panel, where there is no
hover to reveal a tooltip. **About left the navbar in the same change**: the page
stays (it is CMS-backed and organiser-editable), `AppFooter`'s Platform column
links it from every route in both groups — `AppShell` mounts that footer for
`(app)` as well as `(public)` — and Privacy and Terms are the same kind of page
and were never in the bar either. Absent-everywhere is what the "every entry on
every page" rule asks for; absent-only-once-you-sign-in is what it forbids.

**3. Bringing `origin/main` in (2026-08-06)** —
`docs/review-main-2026-08-06.md`: 183 commits, 746 files, reviewed from code on
both sides. **The merge is not additive everywhere** — main DELETED the
Expand Down
63 changes: 52 additions & 11 deletions .claude/skills/hackathon-e2e/tests/smoke/02-login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,21 @@ for (const persona of ALL_PERSONAS) {
).toBeVisible()
})

test(`the public shell links to the hackathon list and About`, async ({
page,
}) => {
test(`the public shell links to the hackathon list`, async ({ page }) => {
await page.goto("/")
// The footer is a <nav> too — scope to the header.
const nav = page.locator("header").getByRole("navigation").first()

// There is no separate "Home" entry: the logo is the platform instance
// and doubles as home, which is why it was dropped rather than added.
//
// "All Hackathons", not "Hackathons": the bar's own wordmark already reads
// "Hackathons", and the scope is what tells this entry apart from the
// Dashboard beside it. Same label signed in or out — a word that renames
// itself with your session is the same bug as one that re-points itself.
await expect(
nav.getByRole("link", { name: "Hackathons", exact: true }),
nav.getByRole("link", { name: "All Hackathons", exact: true }),
).toHaveAttribute("href", "/hackathon")
await expect(
nav.getByRole("link", { name: "About", exact: true }),
).toHaveAttribute("href", "/about")

// Your own events are a SEPARATE entry, not the same word pointing
// somewhere else once you sign in: "Hackathons" named a list and reached
Expand All @@ -67,17 +67,58 @@ for (const persona of ALL_PERSONAS) {

// Re-specified: About used to be hidden inside the app shell as a
// "marketing link", so the nav had three entries on the way in and two
// once you arrived — it changed shape under you. Every entry is present
// on every page now, which is the whole point of a top nav.
// once you arrived — it changed shape under you. Every entry it carries is
// present on every page now, which is the whole point of a top nav.
for (const [label, href] of [
["Dashboard", /\/dashboard$/],
["Hackathons", /\/hackathon$/],
["About", /\/about$/],
["All Hackathons", /\/hackathon$/],
] as const) {
await expect(
nav.getByRole("link", { name: label, exact: true }),
).toHaveAttribute("href", href)
}

// And the bar carries NOTHING else: the two entries people use daily read
// as a pair to tell apart, not as items in a list. This is the assertion
// that About left the navbar for good — a count, so a third entry added
// later has to argue for itself here first.
await expect(nav.getByRole("link")).toHaveCount(2)
})

test(`About left the navbar but is still reachable from the footer`, async ({
page,
}) => {
// Re-specified rather than deleted (the nav used to assert About WAS in
// the bar). The page is CMS-backed and organiser-editable, so what had to
// be re-checked is not that the link is gone but that removing it stranded
// nothing: the footer is the inbound link, and AppShell mounts that footer
// for the signed-in (app) group as well as (public) — the split once gave
// only (public) a footer, which is exactly how this could regress.
//
// Scoped to the footer's PLATFORM column, not the footer: the SDSC column
// beside it links datascience.ch's own About, so a footer-wide "About"
// matches two links and a page-wide one would also match the nav entry
// this test says is gone.
const platformAbout = () =>
page
.locator("footer")
.getByRole("navigation", { name: "Platform" })
.getByRole("link", { name: "About", exact: true })

for (const path of ["/", "/dashboard"]) {
await page.goto(path)

const nav = page.locator("header").getByRole("navigation").first()
await expect(nav.getByRole("link", { name: "About", exact: true })).toHaveCount(0)

await expect(page.locator("footer")).toBeVisible()
await expect(platformAbout()).toHaveAttribute("href", "/about")
}

// Followed, not just asserted: a link's href proves where it points, not
// that the destination answers.
await platformAbout().click()
await expect(page).toHaveURL(/\/about$/)
})
})
}
9 changes: 8 additions & 1 deletion components/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,12 @@
"yaml": "^2.8.1",
"zod": "^3.25.76"
},
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808"
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808",
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",
"protobufjs"
]
}
}
13 changes: 8 additions & 5 deletions components/frontend/src/lib/components/layout/AppShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@
} = $props();
</script>

<!-- min-h-screen + flex-1 on <main>: the footer sits at the bottom of the
VIEWPORT on a short page and at the bottom of the DOCUMENT on a long one,
rather than floating halfway up an empty screen. -->
<div class="flex min-h-screen flex-col">
<!-- No min-h-screen here. That used to stretch this container to at least
100vh, which — via flex-1 on <main> — pinned the footer to the exact
bottom edge of the viewport even on a short page: it was visible on
first paint, with nothing to scroll. The footer should only come into
view once the page's own content actually reaches it, so height here is
the natural sum of header + content + footer, nothing forced. -->
<div class="flex flex-col">
<NavBar {session} />
<main class="flex-1">
<main>
{@render children()}
</main>
<AppFooter />
Expand Down
134 changes: 99 additions & 35 deletions components/frontend/src/lib/components/layout/NavBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import type { Session } from '@auth/sveltekit';
import LightSwitch from './LightSwitch.svelte';
import UserCog from 'lucide-svelte/icons/user-cog';
import LayoutDashboard from 'lucide-svelte/icons/layout-dashboard';
import Compass from 'lucide-svelte/icons/compass';
import { loginDestination } from '$lib/utils/returnTo';

// The header carries identity, theme and sign-out — no administration entry.
Expand All @@ -16,6 +18,20 @@
// inside a hackathon an admin now returns to the dashboard first, via the
// wordmark, rather than jumping straight there from the header — on a phone
// as much as on a desktop, since the mobile panel drops the entry too.
//
// The primary nav is TWO entries: Dashboard (yours) and All Hackathons
// (everyone's). About is gone from it — the page stays, the entry does not.
// It is a SitePage that most people read once, if ever, and the footer's
// Platform column already links it from every page in the app (AppFooter,
// beside Privacy and Terms, which are the same kind of page and were never
// in the bar). Spending a third of the primary nav on it made the two
// entries that DO get used every day one item in a list of three rather
// than a pair to tell apart.
//
// "Every entry present on every page" still holds — that rule was about an
// entry appearing and disappearing under you as you moved through the app,
// which is why About was hoisted out of the app shell in the first place. It
// is now absent consistently, which the rule permits and the footer covers.
let {
session,
}: {
Expand All @@ -40,6 +56,46 @@
$page.url.pathname === '/hackathon' || $page.url.pathname.startsWith('/hackathon/')
);

// What each entry is FOR, in one line, stated once and rendered on both
// surfaces.
//
// Two distinct labels were not enough on their own: "Dashboard" and
// "Hackathons" are both nouns for a list of hackathons, and nothing in
// either word says whose. So the pair is separated three ways instead of
// one — the label names the SCOPE ("All Hackathons", against a Dashboard
// that is yours), an icon carries it pre-reading (a personal panel against
// a compass for browsing), and the sentence below spells it out where there
// is room for a sentence.
//
// "All Hackathons" rather than "Hackathons" for a second reason as well:
// the wordmark to its left already reads "Hackathons", so the bare noun was
// the same word twice in one bar, once as the platform's name and once as a
// destination inside it.
// Phrased as a contrast, not as two independent blurbs: "the events you're
// part of" against "every event", so reading either one answers the question
// the other raises.
const DASHBOARD_HINT = "The events you're part of";
const BROWSE_HINT = 'Every event on the platform, searchable';

// The desktop tab. `inline-flex` so the icon sits on the label's baseline
// row and the active underline spans both rather than the text alone.
const TAB =
'inline-flex items-center gap-1.5 pb-0.5 text-sm font-medium no-underline hover:text-accent-ink';
const TAB_ON = 'text-ink shadow-[inset_0_-2px_0_var(--color-accent)]';
const TAB_OFF = 'text-ink-2';

// The row vocabulary is SidebarNavSection's, so the two navigations read as
// one system rather than drifting into separate dialects of the same idea.
//
// `min-h-10` rather than `h-10`: the two primary entries carry their hint
// underneath, and a fixed height would clip the second line at the widths
// where it wraps. The single-line rows below keep the same 2.5rem they had.
const ROW =
'flex min-h-10 items-center gap-2 rounded-control px-2 py-1.5 text-sm no-underline transition-colors';
const ROW_ACTIVE = 'bg-raised font-medium text-accent-ink';
const ROW_IDLE = 'text-ink-3 hover:text-ink-2';
const ROW_HINT = 'text-xs font-normal text-ink-3';

// Where signing in returns you.
//
// A PARKED destination wins. When a guard bounced someone off a deep link it
Expand All @@ -65,17 +121,6 @@
)
);

const TAB = 'pb-0.5 text-sm font-medium no-underline hover:text-accent-ink';
const TAB_ON = 'text-ink shadow-[inset_0_-2px_0_var(--color-accent)]';
const TAB_OFF = 'text-ink-2';

// The row vocabulary is SidebarNavSection's, so the two navigations read as
// one system rather than drifting into separate dialects of the same idea.
const ROW =
'flex h-10 items-center rounded-control px-2 text-sm no-underline transition-colors';
const ROW_ACTIVE = 'bg-raised font-medium text-accent-ink';
const ROW_IDLE = 'text-ink-3 hover:text-ink-2';

// Any navigation closes the panel. It sits in the header's own flow rather
// than over the page, so leaving it open would push the destination down
// instead of merely covering it.
Expand Down Expand Up @@ -136,38 +181,47 @@
<span class="truncate text-section max-[389px]:hidden">Hackathons</span>
</a>

<!-- Two entries, and only two: your events, and all of them. About is
deliberately absent (the footer links it) — see the script above. -->
<nav class="hidden items-center gap-6 md:flex">
<!-- Your events, only once you have some. First, because it is where
a signed-in person is going nine times out of ten. -->
<!-- Yours. First, because it is where a signed-in person is going
nine times out of ten, and because "yours" before "everyone's"
is the order the two entries contrast in. -->
{#if session?.user}
<a
href={resolve('/(app)/dashboard')}
aria-current={onDashboard ? 'page' : undefined}
title={DASHBOARD_HINT}
class="{TAB} {onDashboard ? TAB_ON : TAB_OFF}"
>
<LayoutDashboard class="h-4 w-4 shrink-0" aria-hidden="true" />
Dashboard
</a>
{/if}
<!-- "Hackathons" is the browse page — every event, searchable —
<!-- Everyone's: the browse page — every event, searchable —
whoever is asking. The label names a list, so it has to reach
the list; when it pointed at the dashboard while signed in, the
same word meant "yours" or "all" depending on your session. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- static route, matches AppFooter's own convention -->
<a href="/hackathon" aria-current={onHackathons ? 'page' : undefined} class="{TAB} {onHackathons ? TAB_ON : TAB_OFF}">
Hackathons
</a>
<!-- Always here, signed in or out. It used to be hidden inside the
app shell as a "marketing link", which made the nav change
shape the moment you reached the dashboard: three entries on
the way in, two once you arrived. A navigation you cannot point
at is worse than one carrying an entry you rarely need.
same word meant "yours" or "all" depending on your session.
It says "All" now for the same reason it stopped moving: the
scope belongs in the label, not in the reader's session.

About is a real SitePage, served by [slug=sitepage]. The
"Challenges" entry that used to sit beside it pointed at "/" —
no backing entity yet, and a link to nowhere is worse than a
missing one. It comes back the day the feature does. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- SitePage slug, not a typed route -->
<a href="/about" class="{TAB} {TAB_OFF}">About</a>
One label whoever is asking, signed in or out. A word that
renames itself once you have an account is the same bug as one
that re-points itself. -->
<!-- Block-scoped rather than -next-line: the anchor spans several
lines, so the rule reports on the href line, not the tag line
a -next-line comment would cover. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -- static route, matches AppFooter's own convention -->
<a
href="/hackathon"
aria-current={onHackathons ? 'page' : undefined}
title={BROWSE_HINT}
class="{TAB} {onHackathons ? TAB_ON : TAB_OFF}"
>
<Compass class="h-4 w-4 shrink-0" aria-hidden="true" />
All Hackathons
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</nav>

<div class="flex min-w-0 shrink-0 items-center justify-end gap-2 sm:gap-3">
Expand Down Expand Up @@ -264,14 +318,22 @@
class="flex flex-col gap-0.5 border-t border-line px-4 py-2 md:hidden"
>
<!-- Same entries as the bar above, in the same order: the panel is
the bar at 320px, not a second navigation with its own ideas. -->
the bar at 320px, not a second navigation with its own ideas.

What it has that the bar cannot: room for the hint the bar can
only offer as a tooltip — and a phone has no hover, so a
title-only explanation would reach nobody here. -->
{#if session?.user}
<a
href={resolve('/(app)/dashboard')}
aria-current={onDashboard ? 'page' : undefined}
class="{ROW} {onDashboard ? ROW_ACTIVE : ROW_IDLE}"
>
Dashboard
<LayoutDashboard class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex min-w-0 flex-col">
<span>Dashboard</span>
<span class={ROW_HINT}>{DASHBOARD_HINT}</span>
</span>
</a>
{/if}
<!-- Block-scoped rather than -next-line: the anchor spans several
Expand All @@ -283,11 +345,13 @@
aria-current={onHackathons ? 'page' : undefined}
class="{ROW} {onHackathons ? ROW_ACTIVE : ROW_IDLE}"
>
Hackathons
<Compass class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex min-w-0 flex-col">
<span>All Hackathons</span>
<span class={ROW_HINT}>{BROWSE_HINT}</span>
</span>
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- SitePage slug, not a typed route -->
<a href="/about" class="{ROW} {ROW_IDLE}">About</a>
{#if session?.user}
<!-- Below md the account link moves in here with sign-out, for
the same reason sign-out does: the bar has no room for it
Expand Down
Loading