Skip to content

feat: single scroll surface for schedule & move user select to header#612

Open
omarkohl wants to merge 1 commit into
mainfrom
jip/single-scroll-surface-for-the/uxpzvsnw
Open

feat: single scroll surface for schedule & move user select to header#612
omarkohl wants to merge 1 commit into
mainfrom
jip/single-scroll-surface-for-the/uxpzvsnw

Conversation

@omarkohl

@omarkohl omarkohl commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

The grid page had three competing scrollbars (window, inner vertical,
horizontal) that chained into each other. The grid view now owns the viewport
below the nav: a slim pinned toolbar (title, view toggle, name select, event
details behind an About toggle) sits above one container that scrolls both
axes, so the sticky day/location headers and time gutter keep working. Wide
grids can be dragged to pan from anywhere (movement threshold + click
suppression, since empty slots are full-cell links). The global footer is
replaced by an inline copy at the end of the schedule content.

Chosen over window-scroll header syncing and fit-to-width variants after HTML
prototyping (throwaway prototype deleted).

Surface the acting attendee as an always-visible header chip on every page so
it's clear who "you" are and switching is possible on shared devices, replacing
the per-page name selectors.

On the schedule, move event details and the proposals link into the view-toggle
bar (details behind a popup).

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@omarkohl omarkohl changed the title feat: single scroll surface for the schedule grid feat: single scroll surface for schedule & move user select to header Jul 15, 2026
@omarkohl

Copy link
Copy Markdown
Collaborator Author

Changes since last jip send

JJ-COMMIT-DESCRIPTION (+18, -12)
--- JJ-COMMIT-DESCRIPTION
+++ JJ-COMMIT-DESCRIPTION
@@ -1,16 +1,22 @@
-feat: single scroll surface for the schedule grid
+feat: single scroll surface for schedule & move user select to header
 
 The grid page had three competing scrollbars (window, inner vertical,
-horizontal) that chained into each other. The grid view now owns the
-viewport below the nav: a slim pinned toolbar (title, view toggle, name
-select, event details behind an About toggle) sits above one container
-that scrolls both axes, so the sticky day/location headers and time
-gutter keep working. Wide grids can be dragged to pan from anywhere
-(movement threshold + click suppression, since empty slots are
-full-cell links). The global footer is replaced by an inline copy at
-the end of the schedule content.
-
-Chosen over window-scroll header syncing and fit-to-width variants
-after HTML prototyping (throwaway prototype deleted).
+horizontal) that chained into each other. The grid view now owns the viewport
+below the nav: a slim pinned toolbar (title, view toggle, name select, event
+details behind an About toggle) sits above one container that scrolls both
+axes, so the sticky day/location headers and time gutter keep working. Wide
+grids can be dragged to pan from anywhere (movement threshold + click
+suppression, since empty slots are full-cell links). The global footer is
+replaced by an inline copy at the end of the schedule content.
+
+Chosen over window-scroll header syncing and fit-to-width variants after HTML
+prototyping (throwaway prototype deleted).
+
+Surface the acting attendee as an always-visible header chip on every page so
+it's clear who "you" are and switching is possible on shared devices, replacing
+the per-page name selectors.
+
+On the schedule, move event details and the proposals link into the view-toggle
+bar (details behind a popup).
 
 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CHANGELOG.md (+7, -11)
index 0000000000..ccab3c1d27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,19 +6,15 @@
 
 ## [Unreleased]
 
-<<<<<<< conflict 1 of 1
-%%%%%%% diff from: vmtqsyzn 6d0ce54f "deps-dev(deps-dev): bump the all-dependencies group with 6 updates" (original parents)
-\\\\\\\        to: tmnykwom 1e5e1d51 "fix: Delete cohost RSVPs in-transaction. (#606)" (new parents)
-+### Fixed
-+
-+- **Host RSVPs cleared on edit**: adding an attendee as a session host now removes their RSVP to that session, including when an organizer edits the session from the admin panel
-+
-+++++++ uxpzvsnw 26ba42de "feat: single scroll surface for the schedule grid" (original revision)
 ### Changed
 
-- **Smoother schedule scrolling**: the grid view now has a single scroll area instead of nested scrollbars — the event title and view controls stay visible while scrolling, wide schedules can be dragged sideways with the mouse, and the event description and proposals link are tucked behind an "About" toggle next to the title
-
->>>>>>> conflict 1 of 1 ends
+- **Your name is always visible**: the attendee you're acting as now shows as a chip in the header on every page — proposals, voting, and schedule — so it's always clear who "you" are, and you can switch attendee from there (handy for a shared device)
+- **Smoother schedule scrolling**: the grid view now has a single scroll area instead of nested scrollbars, and wide schedules can be dragged sideways with the mouse. The view controls (Grid, Text, RSVP'd) sit on one bar alongside an "Event details" popup and a "Proposals" link; the bar scrolls away with the schedule while the room headers stay pinned. The redundant schedule title is gone, since the header already shows the current event
+
+### Fixed
+
+- **Host RSVPs cleared on edit**: adding an attendee as a session host now removes their RSVP to that session, including when an organizer edits the session from the admin panel
+
 ## [3.0.0] - 2026-07-13
 
 > **Breaking change**: the database backend has switched from Airtable to SQLite. There is no automated migration path — data must be re-entered manually or migrated via a custom script.
app/(site)/[eventSlug]/event.tsx (+84, -92)
index 4d267a6832..4ee17c94df 100644
--- a/app/(site)/[eventSlug]/event.tsx
+++ b/app/(site)/[eventSlug]/event.tsx
@@ -1,5 +1,5 @@
 "use client";
-import { ScheduleToolbar, AboutPanel } from "./schedule-toolbar";
+import { ScheduleToolbar } from "./schedule-toolbar";
 import { DayGrid } from "./day-grid";
 import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
 import { DateTime } from "luxon";
@@ -23,7 +23,6 @@
   const viewSession = searchParams.get("viewSession");
   const kiosk = useKioskMode();
   const [search, setSearch] = useState("");
-  const [aboutOpen, setAboutOpen] = useState(false);
   const [unfoldedDayIds, setUnfoldedDayIds] = useState<Set<string>>(
     () => new Set()
   );
@@ -45,112 +44,105 @@
     });
   const locationsForEvent = locations;
 
-  const toolbar = (
-    <>
-      <ScheduleToolbar
-        event={event}
-        guests={guests}
-        aboutOpen={aboutOpen}
-        onToggleAbout={() => setAboutOpen((open) => !open)}
-      />
-      {aboutOpen && <AboutPanel event={event} />}
-    </>
-  );
+  const toolbar = <ScheduleToolbar event={event} />;
 
-  if (view === "grid") {
-    // Grid view owns the viewport below the nav bar: the toolbar is pinned
-    // and the container under it is the only scroll surface (both axes), so
-    // time, room names and day stay sticky while every new day's day+room
-    // header row replaces the previous one. globals.css locks window
-    // scrolling and hides the site footer while [data-schedule-frame] is
-    // mounted; a footer copy ends the schedule content instead.
-    return (
-      <>
-        <div
-          data-schedule-frame
-          className="fixed inset-x-0 top-16 bottom-0 flex flex-col bg-white"
-        >
-          {toolbar}
-          <div
-            data-testid="schedule-scroll"
-            ref={scrollerRef}
-            // `grid` with a single minmax(max-content, 1fr) column (rather
-            // than block flow) so the fold bar's and footer's `w-full`
-            // stretches to the widest day's grid when it overflows — instead
-            // of falling short when scrolled horizontally — yet still fills
-            // the viewport when the grid is narrower than it.
-            className="flex-1 w-full overflow-auto cursor-grab grid content-start"
-            style={{ gridTemplateColumns: "minmax(max-content, 1fr)" }}
-          >
-            {daysForEvent.map((day) => (
-              <div key={day.id} className="contents">
-                {defaultFoldedDayIds.has(day.id) && (
+  // Both views own the viewport below the nav bar via the same fixed frame, so
+  // the toolbar rests in the same spot and doesn't jump when switching views.
+  // The frame's inner container is the only scroll surface; the toolbar lives
+  // inside it (as the first item) so it scrolls away with the content, leaving
+  // only the sticky room headers pinned in the grid view. globals.css locks
+  // window scrolling and hides the site footer while [data-schedule-frame] is
+  // mounted; a footer copy ends the schedule content instead.
+  const scheduleBody =
+    view === "grid" ? (
+      <div
+        data-testid="schedule-scroll"
+        ref={scrollerRef}
+        // `grid` with a single minmax(max-content, 1fr) column (rather than
+        // block flow) so the toolbar, fold bars and footer stretch to the
+        // widest day's grid when it overflows — instead of falling short when
+        // scrolled horizontally — yet still fill the viewport when the grid is
+        // narrower than it.
+        className="flex-1 w-full overflow-auto cursor-grab grid content-start"
+        style={{ gridTemplateColumns: "minmax(max-content, 1fr)" }}
+      >
+        {toolbar}
+        {daysForEvent.map((day) => (
+          <div key={day.id} className="contents">
+            {defaultFoldedDayIds.has(day.id) && (
+              <DayFoldBar
+                day={day}
+                timezone={event.timezone}
+                folded={isFolded(day.id)}
+                onToggle={() => toggleDayFold(day.id)}
+              />
+            )}
+            {!isFolded(day.id) && (
+              <DayGrid
+                day={day}
+                locations={locationsForEvent}
+                guests={guests}
+              />
+            )}
+          </div>
+        ))}
+        <Footer inline />
+      </div>
+    ) : (
+      <div
+        data-testid="schedule-scroll"
+        ref={scrollerRef}
+        className="flex-1 w-full overflow-auto flex flex-col items-stretch"
+      >
+        {toolbar}
+        <Input
+          className="max-w-3xl w-full my-5 mx-auto"
+          placeholder="Search sessions"
+          value={search}
+          onChange={(event) => setSearch(event.target.value)}
+        />
+        <div className="flex flex-col gap-12 w-full">
+          {daysForEvent.map((day) => (
+            <div key={day.id}>
+              {defaultFoldedDayIds.has(day.id) && (
+                <div className="max-w-3xl mx-auto">
                   <DayFoldBar
                     day={day}
                     timezone={event.timezone}
                     folded={isFolded(day.id)}
                     onToggle={() => toggleDayFold(day.id)}
                   />
-                )}
-                {!isFolded(day.id) && (
-                  <DayGrid
-                    day={day}
-                    locations={locationsForEvent}
-                    guests={guests}
-                  />
-                )}
-              </div>
-            ))}
-            <Footer inline />
-          </div>
+                </div>
+              )}
+              {!isFolded(day.id) && (
+                <DayText
+                  day={day}
+                  search={search}
+                  locations={locationsForEvent}
+                  rsvps={view === "rsvp" ? rsvps : []}
+                  eventSlug={event.slug}
+                />
+              )}
+            </div>
+          ))}
         </div>
-        {viewSession && (
-          <SessionModal sessionId={viewSession} eventSlug={event.slug} />
-        )}
-        {kiosk && <KioskController />}
-      </>
+        <Footer inline />
+      </div>
     );
-  }
 
   return (
-    <div className="flex flex-col items-start w-full">
-      {toolbar}
-      <Input
-        className="max-w-3xl w-full my-5 mx-auto"
-        placeholder="Search sessions"
-        value={search}
-        onChange={(event) => setSearch(event.target.value)}
-      />
-      <div className="flex flex-col gap-12 w-full">
-        {daysForEvent.map((day) => (
-          <div key={day.id}>
-            {defaultFoldedDayIds.has(day.id) && (
-              <div className="max-w-3xl mx-auto">
-                <DayFoldBar
-                  day={day}
-                  timezone={event.timezone}
-                  folded={isFolded(day.id)}
-                  onToggle={() => toggleDayFold(day.id)}
-                />
-              </div>
-            )}
-            {!isFolded(day.id) && (
-              <DayText
-                day={day}
-                search={search}
-                locations={locationsForEvent}
-                rsvps={view === "rsvp" ? rsvps : []}
-                eventSlug={event.slug}
-              />
-            )}
-          </div>
-        ))}
+    <>
+      <div
+        data-schedule-frame
+        className="fixed inset-x-0 top-16 bottom-0 flex flex-col bg-white"
+      >
+        {scheduleBody}
       </div>
       {viewSession && (
         <SessionModal sessionId={viewSession} eventSlug={event.slug} />
       )}
       {kiosk && <KioskController />}
-    </div>
+    </>
   );
 }
 
app/(site)/[eventSlug]/proposals/page.tsx (+2, -10)
index f6c687edc6..e8f0409995 100644
--- a/app/(site)/[eventSlug]/proposals/page.tsx
+++ b/app/(site)/[eventSlug]/proposals/page.tsx
@@ -3,7 +3,6 @@
 import { getRepositories } from "@/db/container";
 import { ProposalActionBar } from "./proposal-action-bar";
 import { ProposalTable } from "./proposal-table";
-import { UserSelect } from "@/app/(site)/user-select";
 import { ProposalModal } from "./proposal-modal";
 
 export const dynamic = "force-dynamic";
@@ -25,8 +24,7 @@
     return <div>Event not found</div>;
   }
 
-  const [guests, proposals, sessions] = await Promise.all([
-    repos.guests.list(),
+  const [proposals, sessions] = await Promise.all([
     repos.sessionProposals.listByEvent(event.id),
     viewProposal ? repos.sessions.listByEvent(event.id) : Promise.resolve([]),
   ]);
@@ -36,13 +34,7 @@
 
   return (
     <div className="container mx-auto px-4 py-8">
-      <div className="flex flex-col gap-1">
-        <label htmlFor="user-selection" className="text-gray-500">
-          My name is:
-        </label>
-        <UserSelect guests={guests} />
-      </div>
-      <div className="mb-6 mt-6">
+      <div className="mb-6">
         <div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-4">
           <div>
             <h1 className="text-3xl font-bold">
app/(site)/[eventSlug]/schedule-toolbar.tsx (+51, -58)
index 883375676c..79a9e64d50 100644
--- a/app/(site)/[eventSlug]/schedule-toolbar.tsx
+++ b/app/(site)/[eventSlug]/schedule-toolbar.tsx
@@ -4,66 +4,68 @@
 import clsx from "clsx";
 import {
   CalendarIcon,
-  ChevronDownIcon,
-  ChevronUpIcon,
   ClipboardDocumentListIcon,
   DocumentTextIcon,
   FlagIcon,
+  InformationCircleIcon,
   LinkIcon,
   TableCellsIcon,
 } from "@heroicons/react/24/outline";
 import { DateTime } from "luxon";
 import Link from "next/link";
-import { UserSelect } from "../user-select";
+import { Modal } from "@/app/(site)/modals";
 import { Markdown } from "@/app/(site)/markdown";
 import { hasPhases } from "@/app/(site)/utils/events";
-import type { Event, Guest } from "@/db/repositories/interfaces";
+import type { Event } from "@/db/repositories/interfaces";
 
-// Slim single-row (wrapping on mobile) header for the schedule views: title,
-// view toggle and name select stay reachable without eating vertical space;
-// everything else (description, dates, proposals link) folds into AboutPanel.
-export function ScheduleToolbar(props: {
-  event: Event;
-  guests: Guest[];
-  aboutOpen: boolean;
-  onToggleAbout: () => void;
-}) {
-  const { event, guests, aboutOpen, onToggleAbout } = props;
-  const Chevron = aboutOpen ? ChevronUpIcon : ChevronDownIcon;
+// Slim single-row (wrapping on mobile) header for the schedule views. The view
+// toggle (Grid/Text/RSVP'd) sits next to two distinct navigation links —
+// "Event details" (opens a popup with dates/description) and "Proposals". The
+// event name is intentionally omitted: the site header already shows it.
+export function ScheduleToolbar(props: { event: Event }) {
+  const { event } = props;
+  const [detailsOpen, setDetailsOpen] = useState(false);
   return (
     <div className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 border-b border-gray-200 bg-white px-2 py-1.5 sm:px-3">
-      <h1 className="text-base font-bold sm:text-lg">{event.name} Schedule</h1>
       <SelectView />
-      <button
-        onClick={onToggleAbout}
-        aria-expanded={aboutOpen}
-        className="flex items-center gap-1 rounded-md py-1.5 px-3 text-xs sm:text-sm text-gray-400 hover:bg-gray-50 ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-rose-400"
-      >
-        About
-        <Chevron className="h-3.5 w-3.5 stroke-2" />
-      </button>
-      <label
-        htmlFor="user-selection"
-        className="flex items-center gap-2 text-xs sm:text-sm text-gray-500"
-      >
-        My name is:
-        <span className="w-44 sm:w-56">
-          <UserSelect guests={guests} />
-        </span>
-      </label>
+      {/* Divider keeps the segmented view toggle visually distinct from the
+          navigation links that follow. */}
+      <span className="hidden h-5 w-px bg-gray-200 sm:block" aria-hidden />
+      <div className="flex items-center gap-x-4 gap-y-1">
+        <button
+          onClick={() => setDetailsOpen(true)}
+          className="flex items-center gap-1 rounded-md py-1.5 px-1 text-xs sm:text-sm text-gray-500 hover:text-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-400"
+        >
+          <InformationCircleIcon className="h-4 w-4 stroke-2" />
+          Event details
+        </button>
+        {hasPhases(event) && (
+          <Link
+            href={`/${event.slug}/proposals`}
+            className="flex items-center gap-1 rounded-md py-1.5 px-1 text-xs sm:text-sm text-gray-500 hover:text-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-400"
+          >
+            <ClipboardDocumentListIcon className="h-4 w-4 stroke-2" />
+            Proposals
+          </Link>
+        )}
+      </div>
+      <Modal open={detailsOpen} setOpen={setDetailsOpen}>
+        <EventDetails event={event} />
+      </Modal>
     </div>
   );
 }
 
-// The event details that used to sit above the grid: dates, website,
-// description and the proposals link. Shown below the toolbar on demand.
-export function AboutPanel(props: { event: Event }) {
+// Dates, website and description for the event, shown in the "Event details"
+// popup opened from the toolbar.
+function EventDetails(props: { event: Event }) {
   const { event } = props;
   const multipleDays = event.start.getTime() !== event.end.getTime();
   return (
-    <div className="max-h-[45dvh] w-full overflow-y-auto border-b border-gray-200 bg-white px-3 py-3 shadow-sm">
-      <div className="flex flex-wrap text-gray-500 text-sm gap-x-5 gap-y-1 font-medium">
-        <span className="flex gap-1 items-center">
+    <div className="max-h-[70dvh] overflow-y-auto">
+      <h2 className="text-lg font-bold text-gray-900">{event.name}</h2>
+      <div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-sm font-medium text-gray-500">
+        <span className="flex items-center gap-1">
           <CalendarIcon className="h-4 w-4 stroke-2" />
           <span>
             {DateTime.fromJSDate(event.start)
@@ -81,28 +83,19 @@
             {event.timezone}
           </span>
         </span>
-        <a
-          className="flex gap-1 items-center hover:underline"
-          href={`https://${event.website}`}
-        >
-          <LinkIcon className="h-4 w-4 stroke-2" />
-          <span>{event.website}</span>
-        </a>
+        {event.website && (
+          <a
+            className="flex items-center gap-1 hover:underline"
+            href={`https://${event.website}`}
+          >
+            <LinkIcon className="h-4 w-4 stroke-2" />
+            <span>{event.website}</span>
+          </a>
+        )}
       </div>
-      <div className="text-gray-900 mt-3">
+      <div className="mt-3 text-gray-900">
         <Markdown>{event.description}</Markdown>
       </div>
-      {hasPhases(event) && (
-        <div className="mt-4">
-          <Link
-            href={`/${event.slug}/proposals`}
-            className="bg-rose-400 hover:bg-rose-500 transition-colors text-white px-4 py-2 rounded-md flex items-center gap-2 max-w-fit"
-          >
-            <ClipboardDocumentListIcon className="h-4 w-4" />
-            View Session Proposals
-          </Link>
-        </div>
-      )}
     </div>
   );
 }
app/(site)/header-user-select.tsx (+57, -0)
new file mode 100644
index 0000000000..bffe0eb5d4
--- /dev/null
+++ b/app/(site)/header-user-select.tsx
@@ -0,0 +1,57 @@
+"use client";
+import { useContext, useState } from "react";
+import clsx from "clsx";
+import { UserCircleIcon } from "@heroicons/react/24/outline";
+import type { Guest } from "@/db/repositories/interfaces";
+import { SelectHosts } from "@/app/select-hosts";
+import { Modal } from "./modals";
+import { UserContext } from "./context";
+
+// The current identity lives in the header so it is always visible who "I" am.
+// The chip shows the selected name (or a prompt to pick one); switching is
+// possible but intentionally gated behind a modal so it isn't encouraged —
+// the only real use case is a shared device.
+export function HeaderUserSelect({ guests }: { guests: Guest[] }) {
+  const { user: currentUser, setUser } = useContext(UserContext);
+  const [open, setOpen] = useState(false);
+  const currentName = guests.find((g) => g.id === currentUser)?.name;
+
+  return (
+    <>
+      <button
+        type="button"
+        onClick={() => setOpen(true)}
+        // aria-label always starts with "Your name" so it's a stable target
+        // regardless of who is selected.
+        aria-label={currentName ? `Your name: ${currentName}` : "Your name"}
+        className={clsx(
+          "flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-inset focus:ring-rose-400",
+          currentName
+            ? "text-gray-600 hover:bg-gray-100"
+            : // No name set: gently draw attention to prompt a selection.
+              "bg-rose-50 text-rose-500 hover:bg-rose-100"
+        )}
+      >
+        <UserCircleIcon className="h-5 w-5 shrink-0 stroke-2" />
+        <span className="max-w-[40vw] truncate sm:max-w-[12rem]">
+          {currentName ?? "Select your name"}
+        </span>
+      </button>
+      <Modal open={open} setOpen={setOpen}>
+        <label htmlFor="user-selection" className="text-gray-500">
+          My name is:
+        </label>
+        <SelectHosts
+          id="user-selection"
+          guests={guests}
+          hosts={guests.filter((guest) => guest.id === currentUser)}
+          setHosts={(hosts) => {
+            setUser?.(hosts?.at(-1)?.id || null);
+            setOpen(false);
+          }}
+          selectMany={false}
+        />
+      </Modal>
+    </>
+  );
+}
app/(site)/layout.tsx (+4, -0)
index e7efed9259..a5c7ca8b70 100644
--- a/app/(site)/layout.tsx
+++ b/app/(site)/layout.tsx
@@ -27,6 +27,9 @@
     href: `/${e.slug}`,
     icon: e.icon ?? null,
   }));
+  // Global attendee list backs the header name selector, which is shown on
+  // every page (guests are global, not per-event).
+  const guests = isAuthenticated ? await getRepositories().guests.list() : [];
   const mapImageUrl = isAuthenticated
     ? (await getRepositories().settings.get()).mapImageUrl
     : "";
@@ -35,6 +38,7 @@
     <UserProvider initialUser={initialUser}>
       <NavBar
         navItems={navItems}
+        guests={guests}
         showLogout={passwordProtected && isAuthenticated}
         showGuestsLink={isAuthenticated}
         mapImageUrl={mapImageUrl}
app/(site)/nav-bar.tsx (+6, -1)
index 0b7da86f2d..82e9eb5a4d 100644
--- a/app/(site)/nav-bar.tsx
+++ b/app/(site)/nav-bar.tsx
@@ -10,7 +10,9 @@
 import Link from "next/link";
 import { MapModal } from "./modals";
 import { LogoutButton } from "./logout-button";
+import { HeaderUserSelect } from "./header-user-select";
 import { EVENT_ICONS } from "@/app/event-icons";
+import type { Guest } from "@/db/repositories/interfaces";
 
 export type NavItem = {
   name: string;
@@ -20,11 +22,13 @@
 
 export default function NavBar({
   navItems,
+  guests,
   showLogout,
   showGuestsLink,
   mapImageUrl,
 }: {
   navItems: NavItem[];
+  guests: Guest[];
   showLogout: boolean;
   showGuestsLink: boolean;
   mapImageUrl: string;
@@ -64,7 +68,7 @@
                     </div>
                   </div>
                 </div>
-                <div className="flex items-center gap-3">
+                <div className="flex min-w-0 items-center gap-3">
                   {showGuestsLink && (
                     <Link
                       href="/guests"
@@ -75,6 +79,7 @@
                     </Link>
                   )}
                   {mapImageUrl && <MapModal mapImageUrl={mapImageUrl} />}
+                  {guests.length > 0 && <HeaderUserSelect guests={guests} />}
                   {showLogout && <LogoutButton />}
                 </div>
               </div>
tests/e2e/add-session.spec.ts (+2, -6)
index 725786e22e..d29586f9cc 100644
--- a/tests/e2e/add-session.spec.ts
+++ b/tests/e2e/add-session.spec.ts
@@ -7,9 +7,7 @@
   await login(page);
 
   await page.goto("/Conference-Gamma");
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
 
   // Reach the form the way a real user does: click a free "+" slot in the grid.
   // We don't care which slot, so take the first.
@@ -43,9 +41,7 @@
   // Click the in-app "Back to schedule" link. This is the client-side
   // navigation that previously served a stale (pre-mutation) overview.
   await page.getByRole("link", { name: /Back to schedule/i }).click();
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
 
   // The new session must be visible WITHOUT reloading (see #253).
   const newSessionLink = page.getByRole("link", { name: sessionTitle });
tests/e2e/admin.spec.ts (+3, -0)
index a218c1bc22..f31365e1e4 100644
--- a/tests/e2e/admin.spec.ts
+++ b/tests/e2e/admin.spec.ts
@@ -1450,6 +1450,9 @@
     // is used by no other spec (Bob Test's keynote slot belongs to
     // rsvp.spec.ts).
     await loginAndGoto(page, "/Conference-Gamma");
+    // Open the header name picker, then filter to Yuki (who sorts last and
+    // would otherwise fall outside the combobox's visible options).
+    await page.getByRole("button", { name: /your name/i }).click();
     await page.getByLabel("My name is:").click();
     await page.keyboard.type("Yuki Tanaka");
     await page.getByRole("option", { name: /Yuki Tanaka/i }).click();
tests/e2e/basic-sanity.spec.ts (+1, -3)
index bac6449170..6bbb7cc291 100644
--- a/tests/e2e/basic-sanity.spec.ts
+++ b/tests/e2e/basic-sanity.spec.ts
@@ -85,9 +85,7 @@
       .getByRole("link", { name: "View Schedule" })
       .click();
 
-    await expect(
-      page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-    ).toBeVisible();
+    await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
     await expect(
       page.getByText("Opening Keynote - Conference Gamma")
     ).toBeVisible();
tests/e2e/helpers/user.ts (+10, -0)
new file mode 100644
index 0000000000..f76a5680c3
--- /dev/null
+++ b/tests/e2e/helpers/user.ts
@@ -0,0 +1,10 @@
+import { Page } from "@playwright/test";
+
+// Selects the current identity via the site header. The active name lives in a
+// header chip (accessible name starts with "Your name"); tapping it opens a
+// modal with the "My name is:" combobox where a guest is picked.
+export async function selectUser(page: Page, name: string | RegExp) {
+  await page.getByRole("button", { name: /your name/i }).click();
+  await page.getByLabel("My name is:").click();
+  await page.getByRole("option", { name }).click();
+}
tests/e2e/kiosk.spec.ts (+2, -4)
index 321b7b8d3c..e7efa9f782 100644
--- a/tests/e2e/kiosk.spec.ts
+++ b/tests/e2e/kiosk.spec.ts
@@ -29,10 +29,8 @@
   await page.clock.install();
   await login(page);
   await page.goto(path);
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
-  // The heading comes from server-rendered HTML, so the page may still be
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
+  // The toolbar comes from server-rendered HTML, so the page may still be
   // hydrating. Only hydrated React reacts to the view toggle by rewriting the
   // URL — once that works, hydration is done and the clock can jump safely.
   await expect(async () => {
tests/e2e/phases.spec.ts (+2, -9)
index 89f3acb3d8..113968899e 100644
--- a/tests/e2e/phases.spec.ts
+++ b/tests/e2e/phases.spec.ts
@@ -1,6 +1,6 @@
-import { Page } from "@playwright/test";
 import { test, expect } from "./helpers/fixtures";
 import { login } from "./helpers/auth";
+import { selectUser } from "./helpers/user";
 
 // Phase-dependent UI, one seeded event per phase:
 //   Conference Alpha — proposal phase
@@ -9,11 +9,6 @@
 // Assertions target the deterministic event-specific "Lightning Talks" seed
 // proposals, which other parallel tests never modify.
 
-async function selectUser(page: Page, name: RegExp) {
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name }).click();
-}
-
 test("proposal phase: proposing is open, voting is not yet available", async ({
   page,
 }) => {
@@ -72,9 +67,7 @@
 }) => {
   await login(page);
   await page.goto("/Conference-Gamma");
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
   // The grid offers free slots for adding sessions
   await expect(
     page.getByRole("link", { name: "Add session" }).first()
tests/e2e/profile.spec.ts (+3, -2)
index 1831dc9ac2..2e2108ae9a 100644
--- a/tests/e2e/profile.spec.ts
+++ b/tests/e2e/profile.spec.ts
@@ -3,11 +3,12 @@
 import sharp from "sharp";
 
 async function selectCurrentUser(page: import("@playwright/test").Page) {
-  // The proposals page has a "My name is:" selector backed by a combobox.
+  // The current identity lives in the header: a chip (accessible name starts
+  // with "Your name") opens a modal with the "My name is:" combobox.
+  await page.getByRole("button", { name: /your name/i }).click();
   await page.getByRole("combobox", { name: /My name is/i }).click();
   await page.getByRole("combobox", { name: /My name is/i }).fill("Alice Test");
   await page.getByRole("option", { name: /Alice Test/i }).click();
-  await page.keyboard.press("Escape");
 }
 
 async function makeImage(width: number, height: number): Promise<Buffer> {
tests/e2e/rsvp.spec.ts (+2, -7)
index 272c291cf0..079f984453 100644
--- a/tests/e2e/rsvp.spec.ts
+++ b/tests/e2e/rsvp.spec.ts
@@ -1,14 +1,10 @@
 import { Page } from "@playwright/test";
 import { test, expect } from "./helpers/fixtures";
 import { login } from "./helpers/auth";
+import { selectUser } from "./helpers/user";
 
 const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "admintest";
 
-async function selectUser(page: Page, name: RegExp) {
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name }).click();
-}
-
 // The add-session form's labels are not wired to their inputs, so locate each
 // listbox through its labelled section (same approach as scheduling.spec.ts).
 function listboxButton(page: Page, section: RegExp) {
@@ -32,8 +28,7 @@
   await page.goto("/Conference-Gamma");
 
   // Bob is not a host of the seeded keynote, so he gets an RSVP button
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name: /Bob Test/i }).click();
+  await selectUser(page, /Bob Test/i);
 
   await page
     .getByRole("link", { name: /Opening Keynote/ })
tests/e2e/schedule-layout.spec.ts (+40, -19)
index d47f4861b1..b2b6a8ea03 100644
--- a/tests/e2e/schedule-layout.spec.ts
+++ b/tests/e2e/schedule-layout.spec.ts
@@ -13,34 +13,57 @@
 test.beforeEach(async ({ page }) => {
   await login(page);
   await page.goto("/Conference-Gamma");
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
+  // The event name lives in the site header now; the schedule toolbar's view
+  // toggle is the readiness signal that the grid has rendered.
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
 });
 
-test("event details are tucked behind the About toggle", async ({ page }) => {
-  // The description (here: its "Venue map" link) is hidden until About opens.
+test("event details open in a popup and the proposals link navigates", async ({
+  page,
+}) => {
+  // The description (here: its "Venue map" link) is hidden until the popup opens.
   const venueMapLink = page.getByRole("link", { name: "Venue map" });
   await expect(venueMapLink).not.toBeVisible();
-  await page.getByRole("button", { name: "About" }).click();
-  await expect(venueMapLink).toBeVisible();
+  await page.getByRole("button", { name: "Event details" }).click();
+  const dialog = page.getByRole("dialog");
+  await expect(dialog.getByRole("link", { name: "Venue map" })).toBeVisible();
+  await dialog.getByRole("button", { name: "Close" }).click();
+  await expect(venueMapLink).not.toBeVisible();
 
-  // The proposals link lives there too and still navigates.
-  await page.getByRole("link", { name: "View Session Proposals" }).click();
+  // The Proposals link sits next to the view toggle and navigates.
+  await page.getByRole("link", { name: "Proposals" }).click();
   await expect(
     page.getByRole("heading", { name: /Conference Gamma: Session Proposals/ })
   ).toBeVisible();
 });
 
-test("one scroll surface: title stays put and the footer ends the schedule", async ({
+test("the toolbar scrolls out of view while the room headers stay pinned", async ({
   page,
 }) => {
-  const heading = page.getByRole("heading", {
-    name: /Conference Gamma Schedule/,
-  });
-
-  // Wheel all the way down over the schedule. Wheel scrolling is applied
-  // asynchronously, so scroll in steps and let it settle before asserting.
+  // The toolbar (view toggle) scrolls with the content; the room headers are
+  // sticky and stay pinned to the top of the scroll surface.
+  const toolbar = page.getByRole("button", { name: "Grid" });
+  const roomHeader = page.getByRole("heading", { name: "Main Hall" }).first();
+  await expect(toolbar).toBeInViewport();
+  await expect(roomHeader).toBeInViewport();
+
+  // Wheel down just enough to push the slim toolbar past the top edge while
+  // staying within the first day (scrolling a full day's height would carry its
+  // sticky header off-screen too). Wheel scrolling is applied asynchronously, so
+  // scroll in steps and let it settle before asserting.
+  await page.mouse.move(250, 400);
+  for (let i = 0; i < 4; i++) {
+    await page.mouse.wheel(0, 100);
+    await page.waitForTimeout(50);
+  }
+  await page.waitForTimeout(300);
+
+  await expect(toolbar).not.toBeInViewport();
+  await expect(roomHeader).toBeInViewport();
+});
+
+test("the footer ends the schedule content", async ({ page }) => {
+  // Wheel all the way down over the schedule.
   await page.mouse.move(250, 400);
   for (let i = 0; i < 12; i++) {
     await page.mouse.wheel(0, 2000);
@@ -48,9 +71,7 @@
   }
   await page.waitForTimeout(500);
 
-  // The toolbar with the title never scrolls away…
-  await expect(heading).toBeInViewport();
-  // …and the footer sits at the end of the schedule content.
+  // The footer sits at the end of the schedule content.
   await expect(
     page.getByRole("link", { name: "Report a Bug" }).locator("visible=true")
   ).toBeInViewport();
tests/e2e/scheduling.spec.ts (+2, -8)
index 45057579b7..bc54a860a9 100644
--- a/tests/e2e/scheduling.spec.ts
+++ b/tests/e2e/scheduling.spec.ts
@@ -1,17 +1,13 @@
 import { Page } from "@playwright/test";
 import { test, expect } from "./helpers/fixtures";
 import { login } from "./helpers/auth";
+import { selectUser } from "./helpers/user";
 
 // All tests run in Conference Gamma (scheduling phase). Each test creates its
 // own uniquely-titled session on the LAST event day at a fixed location and
 // start time, so parallel tests (including add-session.spec.ts, which takes
 // the first free "+" slot on day 1) never compete for the same slot.
 
-async function selectUser(page: Page, name: RegExp) {
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name }).click();
-}
-
 // The form's labels are not wired to their inputs, so locate each listbox
 // through its labelled section (same approach as the hosts combobox in
 // add-session.spec.ts).
@@ -118,9 +114,7 @@
   ).toBeVisible();
 
   await page.getByRole("link", { name: /Back to schedule/i }).click();
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
   await expect(page.getByRole("link", { name: title })).toHaveCount(0);
 
   // Still gone after a full reload
tests/e2e/view-session.spec.ts (+1, -3)
index 687f3b1982..a7e64a81ef 100644
--- a/tests/e2e/view-session.spec.ts
+++ b/tests/e2e/view-session.spec.ts
@@ -7,9 +7,7 @@
   await login(page);
 
   await page.goto("/Conference-Gamma");
-  await expect(
-    page.getByRole("heading", { name: /Conference Gamma Schedule/ })
-  ).toBeVisible();
+  await expect(page.getByRole("button", { name: "Grid" })).toBeVisible();
 
   await page
     .getByRole("link", { name: /Opening Keynote/ })
tests/e2e/voting.spec.ts (+8, -12)
index 79878d0181..eb1b3e60c4 100644
--- a/tests/e2e/voting.spec.ts
+++ b/tests/e2e/voting.spec.ts
@@ -1,5 +1,6 @@
 import { test, expect } from "./helpers/fixtures";
 import { loginAndGoto, login } from "./helpers/auth";
+import { selectUser } from "./helpers/user";
 
 test("should allow voting on proposals with different choices", async ({
   page,
@@ -9,9 +10,8 @@
   // Go to proposals list for Conference Beta (voting phase)
   await page.goto("/Conference-Beta/proposals");
 
-  // Select a user from the "My name is:" dropdown
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name: /Alice Test/i }).click();
+  // Select a user via the header name picker
+  await selectUser(page, /Alice Test/i);
 
   // Choose a proposal created by Charlie Test
   const proposalRow = page.getByRole("row", {
@@ -54,9 +54,8 @@
   // Go to proposals list for Conference Beta (voting phase)
   await page.goto("/Conference-Beta/proposals");
 
-  // Select a user from the "My name is:" dropdown
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name: /Bob Test/i }).click();
+  // Select a user via the header name picker
+  await selectUser(page, /Bob Test/i);
 
   // Click on "Go to Quick Voting!" link
   await page.getByRole("link", { name: /Go to Quick Voting!/i }).click();
@@ -123,8 +122,7 @@
   ]);
 
   // Vote as Alice
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name: /Alice Test/i }).click();
+  await selectUser(page, /Alice Test/i);
 
   const row = page.getByRole("row", { name: new RegExp(title) });
   await expect(row).toBeVisible();
@@ -149,8 +147,7 @@
   // (There is no visible aggregate tally during the voting phase, so the
   // combined count is asserted per-user here; tally aggregation is covered
   // by tests/integration/voting.test.ts.)
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name: /Charlie Test/i }).click();
+  await selectUser(page, /Charlie Test/i);
   await expect(row.getByRole("button", { name: "❤️" })).not.toHaveClass(
     /bg-blue-200/
   );
@@ -167,8 +164,7 @@
   await expect(row.getByRole("button", { name: "⭐" })).toHaveClass(
     /bg-blue-200/
   );
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name: /Alice Test/i }).click();
+  await selectUser(page, /Alice Test/i);
   await expect(row.getByRole("button", { name: "❤️" })).toHaveClass(
     /bg-blue-200/
   );

View the diff on GitHub (may include unrelated changes due to rebases since GitHub does not currently implement git range-diff).
View the diff locally (will only work if you fetched the older commit at some point):
git range-diff main 26ba42d 7b3a82f
jj interdiff -f 26ba42d -t 7b3a82f

@omarkohl
omarkohl force-pushed the jip/single-scroll-surface-for-the/uxpzvsnw branch from 26ba42d to 7b3a82f Compare July 15, 2026 19:16
@omarkohl

Copy link
Copy Markdown
Collaborator Author

Changes since last jip send

CHANGELOG.md (+4, -9)
index 0000000000..171c695d4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,20 +6,15 @@
 
 ## [Unreleased]
 
-<<<<<<< conflict 1 of 1
-%%%%%%% diff from: tmnykwom 1e5e1d51 "fix: Delete cohost RSVPs in-transaction. (#606)" (original parents)
-\\\\\\\        to: srkqmson ee7abb0a "test: e2e test for updating a session, checking emails." (new parents)
-+### Added
-+
-+- **Email notifications**: attendees are emailed when a session they've RSVP'd to changes time or location, hosts are emailed when a session they're hosting changes time or location, and guests are emailed when they're added as a co-host of a session. Each notification can be turned on or off individually from the profile page (requires SMTP and `SITE_URL` to be configured)
-+
-+++++++ uxpzvsnw 7b3a82f7 "feat: single scroll surface for schedule & move user select to header" (original revision)
+### Added
+
+- **Email notifications**: attendees are emailed when a session they've RSVP'd to changes time or location, hosts are emailed when a session they're hosting changes time or location, and guests are emailed when they're added as a co-host of a session. Each notification can be turned on or off individually from the profile page (requires SMTP and `SITE_URL` to be configured)
+
 ### Changed
 
 - **Your name is always visible**: the attendee you're acting as now shows as a chip in the header on every page — proposals, voting, and schedule — so it's always clear who "you" are, and you can switch attendee from there (handy for a shared device)
 - **Smoother schedule scrolling**: the grid view now has a single scroll area instead of nested scrollbars, and wide schedules can be dragged sideways with the mouse. The view controls (Grid, Text, RSVP'd) sit on one bar alongside an "Event details" popup and a "Proposals" link; the bar scrolls away with the schedule while the room headers stay pinned. The redundant schedule title is gone, since the header already shows the current event
 
->>>>>>> conflict 1 of 1 ends
 ### Fixed
 
 - **Host RSVPs cleared on edit**: adding an attendee as a session host now removes their RSVP to that session, including when an organizer edits the session from the admin panel

View the diff on GitHub (may include unrelated changes due to rebases since GitHub does not currently implement git range-diff).
View the diff locally (will only work if you fetched the older commit at some point):
git range-diff main 7b3a82f 6905236
jj interdiff -f 7b3a82f -t 6905236

@omarkohl
omarkohl force-pushed the jip/single-scroll-surface-for-the/uxpzvsnw branch from 7b3a82f to 6905236 Compare July 15, 2026 20:41
@omarkohl

Copy link
Copy Markdown
Collaborator Author

Changes since last jip send

app/(site)/[eventSlug]/event.tsx (+3, -1)
index 4ee17c94df..e7d8b2deb8 100644
--- a/app/(site)/[eventSlug]/event.tsx
+++ b/app/(site)/[eventSlug]/event.tsx
@@ -63,7 +63,9 @@
         // widest day's grid when it overflows — instead of falling short when
         // scrolled horizontally — yet still fill the viewport when the grid is
         // narrower than it.
-        className="flex-1 w-full overflow-auto cursor-grab grid content-start"
+        // `cursor` inherits, so links/buttons (session cells, fold toggles, …)
+        // are reset to their normal cursor rather than showing the grab hand.
+        className="flex-1 w-full overflow-auto cursor-grab grid content-start [&_a]:cursor-pointer [&_button]:cursor-pointer"
         style={{ gridTemplateColumns: "minmax(max-content, 1fr)" }}
       >
         {toolbar}
app/(site)/[eventSlug]/use-drag-to-pan.ts (+14, -2)
index 25d37da448..8ae1beba9f 100644
--- a/app/(site)/[eventSlug]/use-drag-to-pan.ts
+++ b/app/(site)/[eventSlug]/use-drag-to-pan.ts
@@ -25,6 +25,11 @@
       top: number;
     } | null = null;
     let dragged = false;
+    // Set on mouseup after a drag, so the click that follows (even one that
+    // lands outside `el`, e.g. because the drag ended over the nav bar) is
+    // swallowed. Cleared on a timeout rather than by that click itself, since
+    // a click doesn't always follow (see mouseup below).
+    let suppressNextClick = false;
 
     const onMouseDown = (e: MouseEvent) => {
       if (e.button !== 0) return;
@@ -54,13 +59,20 @@
     const onMouseUp = () => {
       start = null;
       el.classList.remove("cursor-grabbing");
+      if (dragged) {
+        suppressNextClick = true;
+        setTimeout(() => {
+          suppressNextClick = false;
+        }, 0);
+      }
+      dragged = false;
     };
     // After a drag the browser still fires a click on the common ancestor of
     // the mousedown/mouseup targets — swallow it so panning never activates
     // whatever the pointer happens to end up on.
     const onClickCapture = (e: MouseEvent) => {
-      if (!dragged) return;
-      dragged = false;
+      if (!suppressNextClick) return;
+      suppressNextClick = false;
       e.preventDefault();
       e.stopPropagation();
     };
app/(site)/header-user-select.tsx (+3, -12)
index bffe0eb5d4..851ec96651 100644
--- a/app/(site)/header-user-select.tsx
+++ b/app/(site)/header-user-select.tsx
@@ -3,7 +3,7 @@
 import clsx from "clsx";
 import { UserCircleIcon } from "@heroicons/react/24/outline";
 import type { Guest } from "@/db/repositories/interfaces";
-import { SelectHosts } from "@/app/select-hosts";
+import { UserSelect } from "./user-select";
 import { Modal } from "./modals";
 import { UserContext } from "./context";
 
@@ -12,7 +12,7 @@
 // possible but intentionally gated behind a modal so it isn't encouraged —
 // the only real use case is a shared device.
 export function HeaderUserSelect({ guests }: { guests: Guest[] }) {
-  const { user: currentUser, setUser } = useContext(UserContext);
+  const { user: currentUser } = useContext(UserContext);
   const [open, setOpen] = useState(false);
   const currentName = guests.find((g) => g.id === currentUser)?.name;
 
@@ -41,16 +41,7 @@
         <label htmlFor="user-selection" className="text-gray-500">
           My name is:
         </label>
-        <SelectHosts
-          id="user-selection"
-          guests={guests}
-          hosts={guests.filter((guest) => guest.id === currentUser)}
-          setHosts={(hosts) => {
-            setUser?.(hosts?.at(-1)?.id || null);
-            setOpen(false);
-          }}
-          selectMany={false}
-        />
+        <UserSelect guests={guests} onSelect={() => setOpen(false)} />
       </Modal>
     </>
   );
app/(site)/user-select.tsx (+3, -0)
index eedbd9e178..362185f16d 100644
--- a/app/(site)/user-select.tsx
+++ b/app/(site)/user-select.tsx
@@ -7,9 +7,11 @@
 export function UserSelect({
   guests,
   showOnlyWhenUserSet,
+  onSelect,
 }: {
   guests: Guest[];
   showOnlyWhenUserSet?: boolean;
+  onSelect?: () => void;
 }) {
   const { user: currentUser, setUser } = useContext(UserContext);
 
@@ -21,6 +23,7 @@
         hosts={guests.filter((guest) => guest.id === currentUser)}
         setHosts={(hosts) => {
           setUser?.(hosts?.at(-1)?.id || null);
+          onSelect?.();
         }}
         selectMany={false}
       />

View the diff on GitHub (may include unrelated changes due to rebases since GitHub does not currently implement git range-diff).
View the diff locally (will only work if you fetched the older commit at some point):
git range-diff main 6905236 5165826
jj interdiff -f 6905236 -t 5165826

@omarkohl
omarkohl force-pushed the jip/single-scroll-surface-for-the/uxpzvsnw branch from 6905236 to 5165826 Compare July 16, 2026 20:41
@omarkohl

Copy link
Copy Markdown
Collaborator Author

Changes since last jip send

No code changes (likely just a rebase).


View the diff on GitHub (may include unrelated changes due to rebases since GitHub does not currently implement git range-diff).
View the diff locally (will only work if you fetched the older commit at some point):
git range-diff main 5165826 27ee2a2
jj interdiff -f 5165826 -t 27ee2a2

@omarkohl
omarkohl force-pushed the jip/single-scroll-surface-for-the/uxpzvsnw branch from 5165826 to 27ee2a2 Compare July 16, 2026 21:00
@omarkohl

Copy link
Copy Markdown
Collaborator Author

Changes since last jip send

No code changes (likely just a rebase).


View the diff on GitHub (may include unrelated changes due to rebases since GitHub does not currently implement git range-diff).
View the diff locally (will only work if you fetched the older commit at some point):
git range-diff main 27ee2a2 29fa494
jj interdiff -f 27ee2a2 -t 29fa494

@omarkohl
omarkohl force-pushed the jip/single-scroll-surface-for-the/uxpzvsnw branch from 27ee2a2 to 29fa494 Compare July 17, 2026 06:02
The grid page had three competing scrollbars (window, inner vertical,
horizontal) that chained into each other. The grid view now owns the viewport
below the nav: a slim pinned toolbar (title, view toggle, name select, event
details behind an About toggle) sits above one container that scrolls both
axes, so the sticky day/location headers and time gutter keep working. Wide
grids can be dragged to pan from anywhere (movement threshold + click
suppression, since empty slots are full-cell links). The global footer is
replaced by an inline copy at the end of the schedule content.

Chosen over window-scroll header syncing and fit-to-width variants after HTML
prototyping (throwaway prototype deleted).

Surface the acting attendee as an always-visible header chip on every page so
it's clear who "you" are and switching is possible on shared devices, replacing
the per-page name selectors.

On the schedule, move event details and the proposals link into the view-toggle
bar (details behind a popup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@omarkohl

Copy link
Copy Markdown
Collaborator Author

Changes since last jip send

tests/e2e/update-session.spec.ts (+1, -5)
index 94aaf80516..c4d7343f56 100644
--- a/tests/e2e/update-session.spec.ts
+++ b/tests/e2e/update-session.spec.ts
@@ -1,6 +1,7 @@
 import { Page } from "@playwright/test";
 import { test, expect } from "./helpers/fixtures";
 import { login } from "./helpers/auth";
+import { selectUser } from "./helpers/user";
 import {
   MAILPIT_API_URL,
   getMessage,
@@ -9,11 +10,6 @@
 
 // Form idioms shared with scheduling.spec.ts: the form's labels are not wired
 // to their inputs, so locate each listbox through its labelled section.
-async function selectUser(page: Page, name: RegExp) {
-  await page.getByLabel("My name is:").click();
-  await page.getByRole("option", { name }).click();
-}
-
 function listboxButton(page: Page, section: RegExp) {
   return page
     .locator("div")

View the diff on GitHub (may include unrelated changes due to rebases since GitHub does not currently implement git range-diff).
View the diff locally (will only work if you fetched the older commit at some point):
git range-diff main 29fa494 cd0bdd6
jj interdiff -f 29fa494 -t cd0bdd6

@omarkohl
omarkohl force-pushed the jip/single-scroll-surface-for-the/uxpzvsnw branch from 29fa494 to cd0bdd6 Compare July 17, 2026 06:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant