Skip to content

Commit

Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar

syntaqxcommitted Parentd4f2742Browse files
36 files+316−890/36 viewed
+24−0
601601 `pin_project`, `unpin_project` and `reorder_pinned_projects` actions
602602 over MCP.
603603
604+### On your phone
605+
606+On a screen narrower than a tablet, g1t keeps the same places and moves
607+them within reach of your thumb:
608+
609+| | What it does |
610+| --- | --- |
611+| **The tabs along the bottom** | **Home**, **Code** (or **Docs**, if you don't use Code in this workspace), **Chat**, **Agents** and **Inbox**, each with what is unread. They step aside while the keyboard is up and inside a conversation. |
612+| **The menu button** (☰), beside the workspace's icon at the top left | Opens the sidebar of the mode you are in from the left: the same lists and links as on a computer. Inside a project, that is the project's own list; on a workspace or settings page, the Workspace or account sidebar. Tap outside it, or open a page, and it closes. |
613+| **The tab you are already on** | Tap it again to open that mode's sidebar too. |
614+| **The workspace's icon** at the top left | Everything else, from the bottom: **Docs** first, then the workspace's **Overview**, **People**, **Teams**, **Usage and billing**, **Integrations** and **Settings**; switching workspaces; help; and your status, profile, settings and signing out. |
615+
616+Inside a [project](/guides/projects/), its pages (**Overview**, **Code**,
617+**Issues**, **Pull requests**, **Agents**, **Workflows**, **Deployments**,
618+**Insights** and, for the roles that see them, **Security** and
619+**Settings**) run in a row under its name that scrolls sideways, with the
620+page you are on kept in view, so issues and pull requests are one tap
621+away. Pages with more than one view, such as **Files**, **Commits** and
622+**Branches**, show those as a second row of tabs.
623+
624+Menus stay inside the screen, dialogs rise from the bottom and sit on top
625+of the keyboard while you type, and nothing scrolls the page sideways: a
626+wide file, diff or table scrolls within its own box.
627+
604628 ## Mission control
605629
606630 Mission control, `g1t.sh` when you are signed in, is your home page. It
+4−0
166166 */
167167 :root {
168168 --tabbar-h: 0px;
169+ /* The top bar's height (components/shell.tsx), for what sticks just under it. */
170+ --topbar-h: 3.5rem;
169171 }
170172 @media (max-width: 767px) {
171173 :root {
172174 --tabbar-h: calc(3.5rem + env(safe-area-inset-bottom));
175+ /* On a phone it grows by the notch. */
176+ --topbar-h: calc(3.5rem + env(safe-area-inset-top));
173177 }
174178 }
175179
+1−1
724724 </div>
725725 </FormSection>
726726
727− <div className="sticky bottom-0 z-10 -mx-4 flex items-center justify-end gap-3 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur sm:-mx-8 sm:px-8">
727+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 z-10 -mx-4 flex items-center justify-end gap-3 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur sm:-mx-8 sm:px-8">
728728 {Object.keys(e).length > 0 && <p className="mr-auto text-sm text-danger">Check the fields marked above.</p>}
729729 <button
730730 type="submit"
+1−1
2323 return (
2424 <section
2525 aria-label="Analytics cookie"
26− className="fixed inset-x-3 bottom-3 z-[60] mx-auto max-w-lg rounded-2xl bg-surface p-4 text-sm shadow-2xl shadow-black/50 ring-1 ring-line-strong sm:inset-x-auto sm:right-4 sm:bottom-4"
26+ className="fixed inset-x-3 bottom-3 z-[60] max-md:bottom-[calc(var(--tabbar-h)+0.75rem)] max-md:in-data-[keyboard=open]:bottom-3 mx-auto max-w-lg rounded-2xl bg-surface p-4 text-sm shadow-2xl shadow-black/50 ring-1 ring-line-strong sm:inset-x-auto sm:right-4 sm:bottom-4"
2727 >
2828 <p className="leading-6 text-fg-soft">
2929 May g1t count how its pages are used? It sets one analytics cookie, and names inside your workspaces are
+2−2
9292 <Primitive.Content
9393 aria-describedby={undefined}
9494 onCloseAutoFocus={onCloseAutoFocus}
95− className="fixed top-[12vh] left-1/2 z-50 w-[calc(100vw-2rem)] max-w-xl -translate-x-1/2 overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60 outline-none"
95+ className="fixed top-[12vh] left-1/2 z-50 w-[calc(100vw-2rem)] max-sm:top-[calc(env(safe-area-inset-top)+0.5rem)] max-sm:w-[calc(100vw-1rem)] max-w-xl -translate-x-1/2 overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60 outline-none"
9696 >
9797 <Primitive.Title className="sr-only">Go to or search</Primitive.Title>
9898 {/* Filtering is done here: search already filtered its results. */}
109109 />
110110 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
111111 </div>
112− <CommandList className="max-h-[55vh] p-1.5">
112+ <CommandList className="max-h-[55vh] p-1.5 max-sm:max-h-[calc(var(--vv-height,100dvh)-env(safe-area-inset-top)-5.5rem)]">
113113 <CommandEmpty>Nothing matches.</CommandEmpty>
114114 {trimmed && (
115115 <CommandGroup heading="Search">
+1−1
113113 <PopoverTrigger
114114 aria-label={`${headline}: ${checksTally(checks)}`}
115115 className={cn(
116− "relative z-10 inline-flex size-6 shrink-0 animate-fade-in items-center justify-center rounded-md transition-colors hover:bg-raised data-[state=open]:bg-raised",
116+ "relative z-10 inline-flex size-6 shrink-0 pointer-coarse:size-8 animate-fade-in items-center justify-center rounded-md transition-colors hover:bg-raised data-[state=open]:bg-raised",
117117 className,
118118 )}
119119 >
+2−2
183183 {(memory.seen ?? 1) > 1 && <span>seen {memory.seen} times</span>}
184184 {memory.confidence != null && <span>{Math.round(memory.confidence * 100)}% sure</span>}
185185 </div>
186− <p className="mt-1.5 text-sm whitespace-pre-wrap">{memory.text}</p>
186+ <p className="mt-1.5 text-sm whitespace-pre-wrap wrap-break-word">{memory.text}</p>
187187 {memory.source.evidence && (
188− <p className="mt-1 border-l-2 border-line pl-2 text-xs text-muted whitespace-pre-wrap">{memory.source.evidence}</p>
188+ <p className="mt-1 border-l-2 border-line pl-2 text-xs text-muted whitespace-pre-wrap wrap-break-word">{memory.source.evidence}</p>
189189 )}
190190 <p className="mt-1.5 flex flex-wrap gap-x-3 text-xs text-faint">
191191 <CandidateSource memory={memory} />
+3−3
187187 type="button"
188188 aria-label={`Comment on line ${line}`}
189189 onClick={onClick}
190− className="absolute top-0 -right-2.5 z-10 hidden size-5 items-center justify-center rounded bg-accent text-bg group-hover:flex focus-visible:flex"
190+ className="absolute top-0 -right-2.5 z-10 hidden size-5 items-center justify-center rounded bg-accent text-bg group-hover:flex focus-visible:flex pointer-coarse:-right-1 pointer-coarse:flex pointer-coarse:size-7"
191191 >
192192 <MessageSquarePlus size={12} />
193193 </button>
767767 const allCollapsed = files.every((file) => collapsed.has(file.path));
768768 return (
769769 <div ref={frame}>
770− <div ref={toolbar} className="sticky top-14 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
770+ <div ref={toolbar} className="sticky top-(--topbar-h) z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
771771 <span className="text-sm text-muted">
772772 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
773773 </span>
830830 side="left"
831831 // No keyboard popping up over the list on a phone: the filter is a tap away.
832832 onOpenAutoFocus={(event) => event.preventDefault()}
833− className="inset-x-0 top-auto bottom-0 h-[80dvh] max-w-none rounded-t-2xl border-t border-r-0 data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in sm:max-w-none xl:hidden"
833+ className="inset-x-0 top-auto bottom-0 h-[80dvh] max-w-none rounded-t-2xl pt-0 border-t border-r-0 data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in sm:max-w-none xl:hidden"
834834 >
835835 <SheetHeader>
836836 <SheetTitle>Files changed</SheetTitle>
+7−2
115115 function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, provider }: DocEditorProps & { provider: DocsProvider }) {
116116 const editable = canDo(role, "edit");
117117 const colour = cursorColour(me.name);
118+ // Below 1280px the comments sit under the page: opening them goes there.
119+ const comments = useRef<HTMLElement>(null);
120+ useEffect(() => {
121+ if (showComments && window.matchMedia("(max-width: 1279px)").matches) comments.current?.scrollIntoView({ behavior: "smooth", block: "start" });
122+ }, [showComments]);
118123
119124 const threadStore = useMemo(() => {
120125 const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth();
301306 {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />}
302307 </div>
303308 {showComments && (
304− <aside aria-label="Comments" className="hidden xl:block">
305− <div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto [scrollbar-width:thin]">
309+ <aside ref={comments} aria-label="Comments" className="scroll-mt-20 max-xl:border-t max-xl:border-line max-xl:pt-6">
310+ <div className="xl:sticky xl:top-20 xl:max-h-[calc(100dvh-6rem)] xl:overflow-y-auto xl:[scrollbar-width:thin]">
306311 <ThreadsSidebar filter="all" sort="position" />
307312 </div>
308313 </aside>
+1−1
311311 </fieldset>
312312
313313 {editable ? (
314− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
314+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
315315 <SubmitButton pending="Saving…" busy={saving}>
316316 Save guardrails
317317 </SubmitButton>
+4−1
44
55 import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts";
66
7+import { WaitingCards } from "./notifications/card-actions";
78 import { SubmitButton } from "./ui";
89 import { Badge, type BadgeTone } from "./ui/badge";
910 import { Hint } from "./ui/hint";
174175 }
175176
176177 const ICON_BUTTON =
177− "flex size-7 items-center justify-center rounded-md border border-line bg-surface text-muted transition-colors outline-none hover:border-line-strong hover:text-fg focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-60";
178+ "flex size-7 max-sm:size-10 items-center justify-center rounded-md border border-line bg-surface text-muted transition-colors outline-none hover:border-line-strong hover:text-fg focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-60";
178179
179180 /** What an empty list says, for its tab. */
180181 export function InboxEmpty({ tab, view }: { tab: InboxTab; view?: "inbox" | "saved" | "done" }) {
304305 <InboxTabs tab={tab} counts={counts} onChange={setTab} />
305306 </div>
306307 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
308+ {/* Chat cards waiting on you (live notifications), to act on from here. */}
309+ <WaitingCards onNavigate={() => setOpen(false)} />
307310 {items == null ? (
308311 list.state === "idle" && list.data?.tab === tab ? (
309312 <p className="text-sm text-muted">The inbox could not be loaded. Try again in a moment.</p>
+1−1
151151 : code}
152152 </code>
153153 </pre>
154− <div className="absolute top-2 right-2 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
154+ <div className="absolute top-2 right-2 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
155155 {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>}
156156 <button
157157 type="button"
+4−4
114114 }, [fetcher.state, fetcher.data]);
115115 return (
116116 <Dialog open={open} onOpenChange={setOpen}>
117− <DialogTrigger aria-label="Edit" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg">
117+ <DialogTrigger aria-label="Edit" className="rounded p-1 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg">
118118 <Pencil size={13} />
119119 </DialogTrigger>
120120 <DialogContent>
156156 </span>
157157 )}
158158 </div>
159− <p className="mt-1.5 text-sm whitespace-pre-wrap">{memory.text}</p>
159+ <p className="mt-1.5 text-sm whitespace-pre-wrap wrap-break-word">{memory.text}</p>
160160 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
161161 <Source memory={memory} />
162162 <span>
174174 aria-label={pinned ? "Unpin" : "Pin"}
175175 onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })}
176176 disabled={fetcher.state !== "idle"}
177− className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
177+ className="rounded p-1 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
178178 >
179179 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
180180 </button>
181181 </Hint>
182182 <EditMemory memory={memory} action={action} />
183183 <AlertDialog>
184− <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger">
184+ <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-danger">
185185 <Trash2 size={13} />
186186 </AlertDialogTrigger>
187187 <AlertDialogContent>
+2−2
477477 <span
478478 className={cn(
479479 "mb-1 text-[0.625rem] tabular-nums",
480− today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100",
480+ today ? "text-fg-soft" : "text-faint opacity-0 group-hover:opacity-100 max-md:hidden",
481481 )}
482482 >
483483 {total}
489489 ))}
490490 {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />}
491491 </div>
492− <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 group-hover:block">
492+ <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 md:group-hover:block">
493493 <p className="font-medium text-fg">{day.label}</p>
494494 <p className="mt-0.5 flex items-center gap-1.5 text-muted">
495495 <span className="size-1.5 rounded-full bg-accent" /> {day.agents} by agents on their own
+121−24
66 BookOpen,
77 Building2,
88 Check,
9+ ChevronRight,
910 CircleUserRound,
1011 Code2,
1112 House,
2728 import { type ReactNode, useEffect, useRef, useState } from "react";
2829 import { Link, useLocation, useNavigate, useSubmit } from "react-router";
2930
30−import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
31+import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
3132
3233 import { Avatar } from "./ui";
34+import { TabStrip } from "./ui/tab-strip";
3335 import { StatusDialog } from "./presence";
3436 import { setPresence, useOwnPresence } from "../lib/notify-client";
3537 import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence";
3638 import { STATUS_URL } from "../lib/status";
3739 import { modeOf } from "../lib/workspace-nav";
40+import { PROJECT_PAGE_LINKS, type ProjectPage, projectPageAt, projectPages } from "../lib/chrome";
41+import { ROADMAP, type RoadmapItem } from "../lib/roadmap";
3842
3943 /**
4044 * The phone's layout (below 768px): a tab bar along the bottom, the
4953
5054 /**
5155 * Follows the visual viewport, which shrinks when the keyboard opens: its
52− * height and top go on the page as `--vv-height` and `--vv-top`, and
56+ * height and top go on the page as `--vv-height` and `--vv-top`, what
57+ * the keyboard covers as `--keyboard-inset`, and
5358 * `data-keyboard="open"` on <html> while the keyboard is up, so a
5459 * composer can sit just above it and the tab bar can step aside.
5560 */
6166 const update = () => {
6267 root.style.setProperty("--vv-height", `${viewport.height}px`);
6368 root.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
69+ // How much of the bottom the keyboard covers, for a sheet to sit on it.
70+ root.style.setProperty("--keyboard-inset", `${Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)}px`);
6471 const keyboard = window.innerHeight - viewport.height > 140;
6572 if (keyboard) root.dataset.keyboard = "open";
6673 else delete root.dataset.keyboard;
118125 /**
119126 * The tab bar along the bottom of a phone: Home, Code (Docs, for a member
120127 * without Code), Chat, Agents and the Inbox, each with what is unread. It
121− * steps aside while the keyboard is up and inside a conversation.
128+ * steps aside while the keyboard is up and inside a conversation. The tab
129+ * you are in, tapped again, opens its mode's menu.
122130 */
123−export function MobileTabBar({ workspace, unread }: { workspace: Membership; unread: { inbox: number; chat: number; mentions: number } }) {
131+export function MobileTabBar({
132+ workspace,
133+ unread,
134+ onReselect,
135+}: {
136+ workspace: Membership;
137+ unread: { inbox: number; chat: number; mentions: number };
138+ /** Tapping the tab you are already in: the shell opens that mode's menu (its sidebar). */
139+ onReselect?: () => void;
140+}) {
124141 const { pathname } = useLocation();
125142 const slug = workspace.slug;
126143 const code = hasCodeAccess(workspace);
157174 prefetch="intent"
158175 aria-current={current ? "page" : undefined}
159176 aria-label={count > 0 ? `${tab.label}, ${count} unread` : tab.label}
177+ onClick={(event) => {
178+ // The tab you are in, tapped again: its menu, as a phone's own apps do.
179+ // Chat's first page on a phone is its sidebar already.
180+ if (!current || !onReselect || (tab.key === "chat" && pathname.replace(/\/$/, "") === tab.to)) return;
181+ event.preventDefault();
182+ onReselect();
183+ }}
160184 className="flex h-full flex-col items-center justify-center gap-0.5 transition-colors active:bg-raised/60"
161185 >
162186 <span className={`relative flex h-7 w-12 items-center justify-center rounded-full transition-colors ${current ? "bg-[#2c2c33] text-fg" : "text-muted"}`}>
321345
322346 /**
323347 * The phone's everything-else: the workspace avatar at the top left opens
324− * it. Your workspaces, Docs, the workspace's own pages, help, and your
325− * account. It replaces the menu sheet below 768px.
348+ * it. Docs and the workspace's own pages first, then your workspaces,
349+ * help, and your account. The mode you are in has its own menu (its
350+ * sidebar), behind the button beside the avatar.
326351 */
327352 export function AvatarSheetButton({ user, workspace }: { user: User; workspace: Membership }) {
328353 const [open, setOpen] = useState(false);
350375 <p className="truncate font-mono text-xs text-muted">g1t.sh/{slug}</p>
351376 </div>
352377 </div>
353− <SheetGroup title="Switch workspace">
354− {(user.workspaces ?? []).map((membership) => (
355− <SheetRow
356− key={membership.slug}
357− to={`/${membership.slug}/-/home`}
358− icon={<Avatar name={membership.slug} image={membership.avatar} size={24} square />}
359− end={membership.slug === slug ? <Check size={18} className="text-accent" /> : null}
360− >
361− {displayName(membership)}
378+ <SheetGroup title="Workspace">
379+ {/* Docs has no tab of its own for someone with Code: it leads here. */}
380+ {hasCodeAccess(workspace) && (
381+ <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />} end={<ChevronRight size={16} className="text-faint" />}>
382+ Docs
362383 </SheetRow>
363− ))}
364− <SheetRow to="/workspaces/new" icon={<Plus />}>
365− New workspace
366− </SheetRow>
367− </SheetGroup>
368− <SheetGroup title="Workspace">
369− <SheetRow to={`/${slug}/-/docs`} icon={<BookOpen />}>
370− Docs
371− </SheetRow>
384+ )}
372385 <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}>
373386 Overview
374387 </SheetRow>
388401 Settings
389402 </SheetRow>
390403 </SheetGroup>
404+ <SheetGroup title="Switch workspace">
405+ {(user.workspaces ?? []).map((membership) => (
406+ <SheetRow
407+ key={membership.slug}
408+ to={`/${membership.slug}/-/home`}
409+ icon={<Avatar name={membership.slug} image={membership.avatar} size={24} square />}
410+ end={membership.slug === slug ? <Check size={18} className="text-accent" /> : null}
411+ >
412+ {displayName(membership)}
413+ </SheetRow>
414+ ))}
415+ <SheetRow to="/workspaces/new" icon={<Plus />}>
416+ New workspace
417+ </SheetRow>
418+ </SheetGroup>
391419 <SheetGroup title="Help">
392420 <SheetRow to="/support" icon={<LifeBuoy />}>
393421 Support
421449 </>
422450 );
423451 }
452+
453+/** Which project page a roadmap page (`soon/<key>`) sits under, by its section. */
454+const SOON_UNDER: Partial<Record<RoadmapItem["section"], ProjectPage>> = {
455+ Code: "code",
456+ Issues: "issues",
457+ Agents: "agents",
458+ Deployments: "deployments",
459+ Observability: "observability",
460+ Security: "security",
461+ Insights: "insights",
462+};
463+const SOON_PAGES: Record<string, ProjectPage> = Object.fromEntries(
464+ ROADMAP.flatMap((item) => (SOON_UNDER[item.section] ? [[item.key, SOON_UNDER[item.section]!] as const] : [])),
465+);
466+
467+/**
468+ * A project's pages on a phone, under its name: the sidebar's own list
469+ * (lib/chrome.ts) as a row of tabs that scrolls sideways, the current one
470+ * kept in view, so Issues and Pull requests are one tap away. From 768px
471+ * the sidebar has them.
472+ */
473+export function ProjectStrip({
474+ base,
475+ member,
476+ can,
477+ settings,
478+ counts,
479+}: {
480+ /** `/<namespace>/<name>`. */
481+ base: string;
482+ member: boolean;
483+ can?: Partial<Abilities>;
484+ /** Whether they see its settings (lib/access.ts `seesSettings`), as the sidebar decides. */
485+ settings?: boolean;
486+ counts?: { issues: number; pulls: number };
487+}) {
488+ const { pathname } = useLocation();
489+ const rest = pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) : "";
490+ const at = projectPageAt(rest, SOON_PAGES);
491+ const pages: (ProjectPage | "overview")[] = ["overview", ...projectPages(member, can).filter((page) => page !== "observability" && page !== "settings"),
492+ ...(settings ?? member ? (["settings"] as const) : []),
493+ ];
494+ const pill = (current: boolean) =>
495+ `flex h-9 items-center gap-1.5 rounded-full px-3.5 text-sm transition-colors ${
496+ current ? "bg-raised font-medium text-fg ring-1 ring-line-strong" : "text-muted active:bg-raised/60"
497+ }`;
498+ return (
499+ <TabStrip label="Project" className="-mx-4 gap-1 px-4 md:hidden">
500+ {pages.map((page) => {
501+ const current = at === page;
502+ const link = page === "overview" ? { label: "Overview", path: "" } : PROJECT_PAGE_LINKS[page];
503+ const count = page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined;
504+ return (
505+ <Link
506+ key={page}
507+ to={link.path ? `${base}/${link.path}` : base}
508+ prefetch="intent"
509+ aria-current={current ? "page" : undefined}
510+ data-active={current || undefined}
511+ className={pill(current)}
512+ >
513+ {link.label}
514+ {count != null && count > 0 && <span className="rounded-full bg-line px-1.5 text-xs tabular-nums text-muted">{count}</span>}
515+ </Link>
516+ );
517+ })}
518+ </TabStrip>
519+ );
520+}
+2−2
10461046 )}
10471047 </Block>
10481048
1049− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
1049+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
10501050 {editable && (
10511051 <SubmitButton pending="Saving…" match={{ intent: "save" }}>
10521052 {existing ? "Save changes" : "Create ruleset"}
12541254 const top = insights.by_rule[0]!.count || 1;
12551255 return (
12561256 <li key={row.rule} className="flex items-center gap-3 text-sm">
1257− <span className="w-56 shrink-0 truncate">{ruleInfo(row.rule)?.label ?? row.rule}</span>
1257+ <span className="w-32 shrink-0 truncate sm:w-56">{ruleInfo(row.rule)?.label ?? row.rule}</span>
12581258 <span className="h-2 grow overflow-hidden rounded-full bg-surface">
12591259 <span className="block h-full rounded-full bg-danger/70" style={{ width: `${Math.max(4, (row.count / top) * 100)}%` }} />
12601260 </span>
+4−4
196196 <Link
197197 to={`?edit=${row.id}`}
198198 aria-label={`Edit ${row.name}`}
199− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
199+ className="rounded-md p-1.5 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-fg"
200200 >
201201 <Pencil size={14} />
202202 </Link>
208208 icon
209209 match={{ intent: "delete", id: row.id }}
210210 aria-label={`Remove ${row.name}`}
211− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
211+ className="rounded-md p-1.5 text-faint max-sm:p-2.5 transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
212212 >
213213 <Trash2 size={14} />
214214 </SubmitButton>
243243 return (
244244 <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}>
245245 <Link to="?" aria-label="Close" className="grow" />
246− <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg shadow-2xl">
246+ <Form method="post" className="flex h-full w-full max-w-xl flex-col border-l border-line bg-bg pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] shadow-2xl">
247247 <div className="flex items-center justify-between border-b border-line px-6 py-4">
248248 <h3 className="font-semibold">{editing ? `Edit ${row.name}` : "Add a secret or variable"}</h3>
249− <Link to="?" aria-label="Close" className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg">
249+ <Link to="?" aria-label="Close" className="flex items-center justify-center rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg max-sm:size-10">
250250 <X size={16} />
251251 </Link>
252252 </div>
+28−8
20712071 /** The sidebars that sit beside the rail, one per mode that has one. */
20722072 type Panel = "home" | "chat" | "docs" | "agents" | "code" | "workspace" | "account";
20732073
2074+/** Each sidebar's name, for the phone's button that opens it. */
2075+const MODE_MENU: Record<Panel, string> = {
2076+ home: "Home",
2077+ chat: "Chat",
2078+ docs: "Docs",
2079+ agents: "Agents",
2080+ code: "Code",
2081+ workspace: "Workspace",
2082+ account: "Account",
2083+};
2084+
20742085 /**
20752086 * The app: a sidebar with the workspace, its repositories and the sections
20762087 * of the one being looked at; a slim bar with search and the account; and
21922203 event.preventDefault();
21932204 (event.currentTarget as HTMLElement | null)?.focus();
21942205 }}
2195− className={`lg:hidden ${rail ? `flex flex-row ${panel ? "w-[21rem] max-w-[92vw] sm:max-w-[21rem]" : "w-20 sm:max-w-20"}` : "w-72 max-w-[85vw] sm:max-w-72"}`}
2206+ // A phone has the tab bar for the modes: the sheet is the mode's
2207+ // sidebar alone, a little wider, its rows tall enough for a thumb.
2208+ className={`lg:hidden ${rail ? `flex flex-row ${panel ? "w-[21rem] max-w-[92vw] sm:max-w-[21rem] max-md:w-[min(20rem,86vw)]" : "w-20 sm:max-w-20"}` : "w-72 max-w-[85vw] sm:max-w-72"}`}
21962209 >
2197− <SheetTitle className="sr-only">Menu</SheetTitle>
2198− {rail}
2199− {panel && <div className={`min-w-0 grow ${rail ? SIDEBAR_BOX : ""}`}>{sidebarNode(true)}</div>}
2210+ <SheetTitle className="sr-only">{ws && panel ? `${MODE_MENU[panel]} menu` : "Menu"}</SheetTitle>
2211+ {rail && <div className={`flex h-full shrink-0 ${panel ? "max-md:hidden" : ""}`}>{rail}</div>}
2212+ {panel && <div className={`min-w-0 grow ${rail ? SIDEBAR_BOX : ""} max-md:[&_nav_a]:min-h-10`}>{sidebarNode(true)}</div>}
22002213 </SheetContent>
22012214
22022215 <div className={`flex min-h-dvh min-w-0 flex-col ${pad} ${tabs ? "pb-(--tabbar-h)" : ""}`}>
22032216 <header
2204− className={`sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 pt-[env(safe-area-inset-top)] pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] backdrop-blur sm:px-6 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] ${mode === "chat" ? "lg:hidden" : ""} ${conversation ? "max-md:hidden" : ""}`}
2217+ className={`sticky top-0 z-30 flex h-14 items-center gap-3 max-md:gap-1.5 border-b border-line bg-bg/85 pt-[env(safe-area-inset-top)] pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] backdrop-blur sm:px-6 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] ${mode === "chat" ? "lg:hidden" : ""} ${conversation ? "max-md:hidden" : ""}`}
22052218 >
22062219 {/* A phone: the workspace's avatar opens everything else (components/mobile.tsx). */}
22072220 {user && ws && <AvatarSheetButton user={user} workspace={ws} />}
2221+ {/* The mode's own menu (its sidebar); on a phone, beside the avatar, wherever the mode has one. */}
22082222 <SheetTrigger
2209− aria-label="Open menu"
2210− className={`rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden ${user && ws ? "max-md:hidden" : ""}`}
2223+ aria-label={ws && panel ? `Open the ${MODE_MENU[panel]} menu` : "Open menu"}
2224+ className={`flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg max-md:-ml-1 max-md:size-10 lg:hidden ${user && ws && !panel ? "max-md:hidden" : ""}`}
22112225 >
22122226 <Menu size={18} />
22132227 </SheetTrigger>
23472361 <InMain.Provider value={true}>{children}</InMain.Provider>
23482362 </main>
23492363 </div>
2350− {user && ws && <MobileTabBar workspace={ws} unread={{ inbox: inboxUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }} />}
2364+ {user && ws && (
2365+ <MobileTabBar
2366+ workspace={ws}
2367+ unread={{ inbox: inboxUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }}
2368+ onReselect={panel ? () => setDrawer(true) : undefined}
2369+ />
2370+ )}
23512371 <CommandPalette
23522372 open={palette}
23532373 onOpenChange={setPalette}
+1−1
125125 </div>
126126 </dl>
127127 ) : (
128− <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm">
128+ <dl className="mt-3 grid grid-cols-1 divide-y divide-line sm:grid-cols-3 sm:divide-x sm:divide-y-0 rounded-xl border border-line bg-surface text-sm">
129129 <div className="px-4 py-3">
130130 <dt className="text-xs text-faint">Charged</dt>
131131 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
+8−3
2424 );
2525 }
2626
27−/** The panel, centred; on a phone it fills the width less a gutter. */
27+/**
28+ * The panel, centred. On a phone (below 640px) it is a sheet from the
29+ * bottom instead, the full width, clear of the home indicator and sitting
30+ * on the keyboard while it is up (`--keyboard-inset`, components/mobile.tsx).
31+ */
2832 export const DIALOG_PANEL =
29− "fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl border border-line-strong bg-surface p-6 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out motion-reduce:animate-none";
33+ "fixed top-1/2 left-1/2 z-50 grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto overscroll-contain rounded-xl border border-line-strong bg-surface p-6 text-fg shadow-2xl shadow-black/60 outline-none data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out motion-reduce:animate-none " +
34+ "max-sm:top-auto max-sm:bottom-(--keyboard-inset,0px) max-sm:left-0 max-sm:w-full max-sm:max-w-none max-sm:translate-x-0 max-sm:translate-y-0 max-sm:max-h-[calc(var(--vv-height,100dvh)-env(safe-area-inset-top)-0.5rem)] max-sm:rounded-b-none max-sm:border-x-0 max-sm:border-b-0 max-sm:pb-[calc(1.5rem+env(safe-area-inset-bottom))] max-sm:data-[state=open]:animate-[sheet-in-bottom_0.24s_cubic-bezier(0.16,1,0.3,1)] max-sm:data-[state=closed]:animate-fade-out";
3035
3136 export function DialogContent({
3237 className,
4247 {showClose && (
4348 <Primitive.Close
4449 aria-label="Close"
45− className="absolute top-4 right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
50+ className="absolute top-4 right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg max-sm:top-2.5 max-sm:right-2.5 max-sm:flex max-sm:size-10 max-sm:items-center max-sm:justify-center"
4651 >
4752 <X size={16} />
4853 </Primitive.Close>
+11−4
1313 export function DropdownMenuContent({
1414 className,
1515 sideOffset = 6,
16+ collisionPadding = 8,
1617 ...props
1718 }: ComponentProps<typeof Primitive.Content>) {
1819 return (
1920 <Primitive.Portal>
2021 <Primitive.Content
2122 sideOffset={sideOffset}
23+ // Never off the edge of a phone: kept 8px inside it, and as tall as there is room for.
24+ collisionPadding={collisionPadding}
2225 className={cn(
23− "z-50 min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-line-strong bg-raised p-1 text-sm shadow-xl shadow-black/40",
26+ "z-50 max-h-(--radix-dropdown-menu-content-available-height) max-w-[calc(100vw-1rem)] min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-line-strong bg-raised p-1 text-sm shadow-xl shadow-black/40",
2427 "data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
2528 className,
2629 )}
3740 return (
3841 <Primitive.Item
3942 className={cn(
40− "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none",
43+ // Taller rows under a finger, so each is a fair target.
44+ "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none pointer-coarse:min-h-10",
4145 "data-highlighted:bg-line data-highlighted:text-fg data-disabled:opacity-50",
4246 "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-faint",
4347 className,
8286 return (
8387 <Primitive.SubTrigger
8488 className={cn(
85− "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none",
89+ // Taller rows under a finger, so each is a fair target.
90+ "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-fg/90 outline-none select-none pointer-coarse:min-h-10",
8691 "data-highlighted:bg-line data-highlighted:text-fg data-[state=open]:bg-line data-[state=open]:text-fg data-disabled:opacity-50",
8792 "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-faint",
8893 className,
98103 export function DropdownMenuSubContent({
99104 className,
100105 sideOffset = 6,
106+ collisionPadding = 8,
101107 ...props
102108 }: ComponentProps<typeof Primitive.SubContent>) {
103109 return (
104110 <Primitive.Portal>
105111 <Primitive.SubContent
106112 sideOffset={sideOffset}
113+ collisionPadding={collisionPadding}
107114 className={cn(
108− "z-50 min-w-44 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-line-strong bg-raised p-1 text-sm shadow-xl shadow-black/40",
115+ "z-50 max-h-(--radix-dropdown-menu-content-available-height) max-w-[calc(100vw-1rem)] min-w-44 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-line-strong bg-raised p-1 text-sm shadow-xl shadow-black/40",
109116 "data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
110117 className,
111118 )}
+3−0
1414 className,
1515 align = "center",
1616 sideOffset = 6,
17+ collisionPadding = 8,
1718 ...props
1819 }: ComponentProps<typeof Primitive.Content>) {
1920 return (
2122 <Primitive.Content
2223 align={align}
2324 sideOffset={sideOffset}
25+ // Kept inside a phone's edges.
26+ collisionPadding={collisionPadding}
2427 className={cn(
2528 "z-50 w-72 max-w-[calc(100vw-2rem)] rounded-lg border border-line-strong bg-raised p-4 text-sm text-fg shadow-xl shadow-black/40 outline-none",
2629 "origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
+3−2
3333 <DialogOverlay />
3434 <Primitive.Content
3535 className={cn(
36− "fixed z-50 flex h-dvh w-full flex-col border-line-strong bg-surface text-fg shadow-2xl shadow-black/60 outline-none motion-reduce:animate-none",
36+ // Clear of the notch and the home indicator on a phone.
37+ "fixed z-50 flex h-dvh w-full flex-col border-line-strong bg-surface pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] text-fg shadow-2xl shadow-black/60 outline-none motion-reduce:animate-none",
3738 SIDES[side],
3839 className,
3940 )}
4344 {showClose && (
4445 <Primitive.Close
4546 aria-label="Close"
46− className="absolute top-4 right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
47+ className="absolute top-[calc(1rem+env(safe-area-inset-top))] right-4 rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg max-sm:top-[calc(0.75rem+env(safe-area-inset-top))] max-sm:right-3 max-sm:flex max-sm:size-10 max-sm:items-center max-sm:justify-center"
4748 >
4849 <X size={16} />
4950 </Primitive.Close>
+19−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { VISITOR_LINKS, projectPages, usesAppShell } from "./chrome.ts";
4+import { VISITOR_LINKS, projectPageAt, projectPages, usesAppShell } from "./chrome.ts";
55
66 test("someone signed in always gets the sidebar", () => {
77 for (const path of ["/", "/pricing", "/acme/web", "/explore", "/does/not/exist"]) {
6161 // Anyone who can read a repository sees its deployments.
6262 assert.ok(visitor.includes("deployments") && projectPages(true).includes("deployments"));
6363 });
64+
65+test("the phone's project strip lights the page a path is under", () => {
66+ assert.equal(projectPageAt(""), "overview");
67+ assert.equal(projectPageAt("blob/main/README.md"), "code");
68+ assert.equal(projectPageAt("commit/abc123"), "code");
69+ assert.equal(projectPageAt("issues/12"), "issues");
70+ assert.equal(projectPageAt("milestones"), "issues");
71+ assert.equal(projectPageAt("pull/3/files"), "pulls");
72+ assert.equal(projectPageAt("queue"), "pulls");
73+ assert.equal(projectPageAt("sessions/abc"), "agents");
74+ assert.equal(projectPageAt("actions/runs/9"), "actions");
75+ assert.equal(projectPageAt("security/secret-scanning"), "security");
76+ assert.equal(projectPageAt("stargazers"), "insights");
77+ assert.equal(projectPageAt("settings/branches"), "settings");
78+ assert.equal(projectPageAt("soon/logs", { logs: "observability" }), "observability");
79+ assert.equal(projectPageAt("soon/boards", { boards: "issues" }), "issues");
80+ assert.equal(projectPageAt("pulse"), null);
81+});
+34−0
107107 (page): page is ProjectPage => page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])),
108108 );
109109 }
110+
111+/**
112+ * A project's pages as the phone's strip of tabs under its name names them
113+ * (routes/repo/layout.tsx): the sidebar's words, and the paths under the
114+ * project at which each is the current one.
115+ */
116+export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = {
117+ code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare"] },
118+ issues: { label: "Issues", path: "issues", also: ["plans", "milestones", "labels"] },
119+ pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue"] },
120+ agents: { label: "Agents", path: "agents", also: ["sessions", "memory"] },
121+ actions: { label: "Workflows", path: "actions", also: [] },
122+ deployments: { label: "Deployments", path: "deployments", also: [] },
123+ observability: { label: "Observability", path: "soon/logs", also: [], soon: true },
124+ security: { label: "Security", path: "security", also: [] },
125+ insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] },
126+ settings: { label: "Settings", path: "settings", also: [] },
127+};
128+
129+/**
130+ * Which of a project's pages `rest` (the path under the project, no
131+ * leading slash) is on: "overview" for the project itself. `soon` says
132+ * which page a roadmap page (`soon/<key>`) sits under.
133+ */
134+export function projectPageAt(rest: string, soon: Record<string, ProjectPage> = {}): ProjectPage | "overview" | null {
135+ const path = rest.replace(/^\/+|\/+$/g, "");
136+ if (path === "") return "overview";
137+ const soonKey = /^soon\/([^/]+)/.exec(path)?.[1];
138+ if (soonKey && soon[soonKey]) return soon[soonKey]!;
139+ for (const [page, link] of Object.entries(PROJECT_PAGE_LINKS) as [ProjectPage, (typeof PROJECT_PAGE_LINKS)[ProjectPage]][]) {
140+ if ([link.path, ...link.also].some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return page;
141+ }
142+ return null;
143+}
+2−2
397397 {ANNOTATION_ICON[note.level]}
398398 <span className="min-w-0">
399399 {note.title && <span className="font-medium">{note.title}: </span>}
400− <span className="whitespace-pre-wrap">{note.message}</span>
400+ <span className="whitespace-pre-wrap wrap-break-word">{note.message}</span>
401401 {note.file && (
402402 <span className="block font-mono text-xs text-faint">
403403 {note.file}
811811 {run.error && (
812812 <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm">
813813 <p className="font-medium text-danger">The workflow file could not be used</p>
814− <p className="mt-1 whitespace-pre-wrap text-fg/85">{run.error}</p>
814+ <p className="mt-1 whitespace-pre-wrap wrap-break-word text-fg/85">{run.error}</p>
815815 </div>
816816 )}
817817 <Notes notes={notes} />
+6−0
1212 import { Pill, SoonPill } from "../../components/ui";
1313 import { Hint } from "../../components/ui/hint";
1414 import { TabStrip } from "../../components/ui/tab-strip";
15+import { ProjectStrip } from "../../components/mobile";
16+import { seesSettings } from "../../lib/access";
1517 import { WatchMenu } from "../../components/notifications";
1618 import { PinButton } from "../../components/pin-button";
1719 import { StarButton } from "../../components/star-button";
213215 />
214216 {/* On the files' pages, About shows them. */}
215217 {!filesPage && <Topics topics={repo.topics} />}
218+ {/* A phone: the project's pages, which the sidebar lists from 768px. */}
219+ <div className="mt-3 md:hidden">
220+ <ProjectStrip base={base} member={member} can={access.can} settings={seesSettings(access)} counts={loaderData.open} />
221+ </div>
216222 {tabs && (
217223 <div className="mt-3">
218224 <PageTabs base={base} tabs={tabs} />
+1−1
4848 return (
4949 <li className="flex gap-2.5 px-4 py-3 text-sm">
5050 <MessageSquare size={14} className="mt-0.5 shrink-0 text-faint" />
51− <p className="min-w-0 grow whitespace-pre-wrap">{entry.text}</p>
51+ <p className="min-w-0 grow whitespace-pre-wrap wrap-break-word">{entry.text}</p>
5252 {at}
5353 </li>
5454 );
+1−1
181181 </RadioGroup>
182182 </Section>
183183
184− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
184+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
185185 <SubmitButton pending="Saving…">Save settings</SubmitButton>
186186 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
187187 <ErrorText>{actionData?.error}</ErrorText>
+1−1
192192 </Link>
193193 </Section>
194194
195− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
195+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
196196 <SubmitButton pending="Saving…" disabled={archived}>
197197 Save settings
198198 </SubmitButton>
+1−1
364364 />
365365 </Section>
366366
367− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
367+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
368368 <SubmitButton name="intent" value="save" pending="Saving…">
369369 {environment.protected ? "Save rules" : "Add rules"}
370370 </SubmitButton>
+1−1
9797 </fieldset>
9898
9999 {owner ? (
100− <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
100+ <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
101101 <SubmitButton pending="Saving…">Save settings</SubmitButton>
102102 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
103103 <ErrorText>{actionData?.error}</ErrorText>
+3−3
203203 <input type="hidden" name="id" value={memory.id} />
204204 <input type="hidden" name="pinned" value={pinned ? "false" : "true"} />
205205 <Hint label={pinned ? "Unpin" : "Pin"}>
206− <button type="submit" aria-label={pinned ? "Unpin" : "Pin"} className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
206+ <button type="submit" aria-label={pinned ? "Unpin" : "Pin"} className="flex size-7 items-center justify-center rounded-md text-faint max-sm:size-10 hover:bg-raised hover:text-fg">
207207 {pinned ? <PinOff size={14} /> : <Pin size={14} />}
208208 </button>
209209 </Hint>
210210 </pin.Form>
211211 <Hint label="Edit">
212− <button type="button" aria-label="Edit" onClick={() => setEditing(true)} className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg">
212+ <button type="button" aria-label="Edit" onClick={() => setEditing(true)} className="flex size-7 items-center justify-center rounded-md text-faint max-sm:size-10 hover:bg-raised hover:text-fg">
213213 <Pencil size={14} />
214214 </button>
215215 </Hint>
219219 fields={{ intent: "forget", id: memory.id }}
220220 fetcherKey={`forget-${memory.id}`}
221221 trigger={
222− <button type="button" aria-label="Forget" className="flex size-7 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-danger">
222+ <button type="button" aria-label="Forget" className="flex size-7 items-center justify-center rounded-md text-faint max-sm:size-10 hover:bg-raised hover:text-danger">
223223 <Trash2 size={14} />
224224 </button>
225225 }
+5−5
279279 </Rail>
280280 <div className="min-w-0 grow rounded-lg border border-accent/30 bg-accent/[0.06] px-3.5 py-2.5">
281281 <p className="flex items-center gap-2 text-xs font-medium text-accent">Goal{when}</p>
282− <p className="mt-1 text-sm whitespace-pre-wrap text-fg">{event.body}</p>
282+ <p className="mt-1 text-sm whitespace-pre-wrap wrap-break-word text-fg">{event.body}</p>
283283 </div>
284284 </li>
285285 );
294294 <span className="font-medium text-fg">{event.by ? `@${event.by}` : "g1t"}</span>
295295 {when}
296296 </p>
297− <p className="mt-0.5 text-sm whitespace-pre-wrap text-fg-soft">{event.body}</p>
297+ <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p>
298298 </div>
299299 </li>
300300 );
341341 <p className="flex items-center gap-2 text-xs text-info">
342342 <span className="font-medium">{event.by ? `@${event.by}` : "Someone"}</span> steered it{when}
343343 </p>
344− <p className="mt-1 text-sm whitespace-pre-wrap text-fg">{event.body}</p>
344+ <p className="mt-1 text-sm whitespace-pre-wrap wrap-break-word text-fg">{event.body}</p>
345345 </div>
346346 </li>
347347 );
353353 </Rail>
354354 <div className="min-w-0 grow">
355355 <p className="flex items-center gap-2 text-xs text-muted">Posted an update in the conversation{when}</p>
356− <p className="mt-0.5 text-sm whitespace-pre-wrap text-fg-soft">{event.body}</p>
356+ <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p>
357357 </div>
358358 </li>
359359 );
367367 <p className="flex items-center gap-2 text-xs text-muted">
368368 {event.by ? <span className="font-medium text-fg">@{event.by}</span> : null} reported back{when}
369369 </p>
370− <p className="mt-0.5 text-sm whitespace-pre-wrap text-fg-soft">{event.body}</p>
370+ <p className="mt-0.5 text-sm whitespace-pre-wrap wrap-break-word text-fg-soft">{event.body}</p>
371371 </div>
372372 </li>
373373 );
+3−3
213213 <div className="group relative -mx-4 h-44 sm:-mx-6 lg:-mx-8" style={{ background: cover }}>
214214 {editable && (
215215 <CoverPicker onChange={(c) => send("update_page", { page_id: page.id, change: { cover: c } })}>
216− <button type="button" className="absolute right-4 bottom-3 rounded-md bg-bg/70 px-2.5 py-1 text-xs text-fg opacity-0 backdrop-blur group-hover:opacity-100 focus-visible:opacity-100">
216+ <button type="button" className="absolute right-4 bottom-3 rounded-md bg-bg/70 px-2.5 py-1 text-xs text-fg opacity-0 backdrop-blur group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100">
217217 Change cover
218218 </button>
219219 </CoverPicker>
221221 </div>
222222 )}
223223 {/* The bar above the page: where it is, who is here, and what can be done. */}
224− <div className="sticky top-14 z-20 -mx-4 flex h-12 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
224+ <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
225225 <div className="min-w-0 grow">
226226 <Crumbs items={crumbs} />
227227 </div>
245245 </span>
246246 )}
247247 <Hint label={showComments ? "Hide comments" : "Comments"}>
248− <button type="button" onClick={() => setShowComments(!showComments)} aria-pressed={showComments} aria-label="Comments" className={`hidden size-8 items-center justify-center rounded-md xl:flex ${showComments ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
248+ <button type="button" onClick={() => setShowComments(!showComments)} aria-pressed={showComments} aria-label="Comments" className={`flex size-8 items-center justify-center rounded-md max-md:size-10 ${showComments ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}>
249249 <MessageSquare size={16} />
250250 </button>
251251 </Hint>
+1−1
208208 <button
209209 type="button"
210210 aria-label={`Manage ${member.username}`}
211− className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
211+ className="rounded-md p-1.5 text-faint transition-colors max-sm:p-2.5 hover:bg-raised hover:text-fg"
212212 >
213213 <Ellipsis size={16} />
214214 </button>