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
36 files+316−890/36 viewed
| 601 | 601 | `pin_project`, `unpin_project` and `reorder_pinned_projects` actions | |
| 602 | 602 | over MCP. | |
| 603 | 603 | ||
| 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 | + | ||
| 604 | 628 | ## Mission control | |
| 605 | 629 | ||
| 606 | 630 | Mission control, `g1t.sh` when you are signed in, is your home page. It |
| 166 | 166 | */ | |
| 167 | 167 | :root { | |
| 168 | 168 | --tabbar-h: 0px; | |
| 169 | + | /* The top bar's height (components/shell.tsx), for what sticks just under it. */ | |
| 170 | + | --topbar-h: 3.5rem; | |
| 169 | 171 | } | |
| 170 | 172 | @media (max-width: 767px) { | |
| 171 | 173 | :root { | |
| 172 | 174 | --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)); | |
| 173 | 177 | } | |
| 174 | 178 | } | |
| 175 | 179 |
| 724 | 724 | </div> | |
| 725 | 725 | </FormSection> | |
| 726 | 726 | ||
| 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"> | |
| 728 | 728 | {Object.keys(e).length > 0 && <p className="mr-auto text-sm text-danger">Check the fields marked above.</p>} | |
| 729 | 729 | <button | |
| 730 | 730 | type="submit" |
| 23 | 23 | return ( | |
| 24 | 24 | <section | |
| 25 | 25 | 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" | |
| 27 | 27 | > | |
| 28 | 28 | <p className="leading-6 text-fg-soft"> | |
| 29 | 29 | May g1t count how its pages are used? It sets one analytics cookie, and names inside your workspaces are |
| 92 | 92 | <Primitive.Content | |
| 93 | 93 | aria-describedby={undefined} | |
| 94 | 94 | 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" | |
| 96 | 96 | > | |
| 97 | 97 | <Primitive.Title className="sr-only">Go to or search</Primitive.Title> | |
| 98 | 98 | {/* Filtering is done here: search already filtered its results. */} | |
| ⋯ | |||
| 109 | 109 | /> | |
| 110 | 110 | <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd> | |
| 111 | 111 | </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)]"> | |
| 113 | 113 | <CommandEmpty>Nothing matches.</CommandEmpty> | |
| 114 | 114 | {trimmed && ( | |
| 115 | 115 | <CommandGroup heading="Search"> | |
| 113 | 113 | <PopoverTrigger | |
| 114 | 114 | aria-label={`${headline}: ${checksTally(checks)}`} | |
| 115 | 115 | 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", | |
| 117 | 117 | className, | |
| 118 | 118 | )} | |
| 119 | 119 | > |
| 183 | 183 | {(memory.seen ?? 1) > 1 && <span>seen {memory.seen} times</span>} | |
| 184 | 184 | {memory.confidence != null && <span>{Math.round(memory.confidence * 100)}% sure</span>} | |
| 185 | 185 | </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> | |
| 187 | 187 | {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> | |
| 189 | 189 | )} | |
| 190 | 190 | <p className="mt-1.5 flex flex-wrap gap-x-3 text-xs text-faint"> | |
| 191 | 191 | <CandidateSource memory={memory} /> |
| 187 | 187 | type="button" | |
| 188 | 188 | aria-label={`Comment on line ${line}`} | |
| 189 | 189 | 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" | |
| 191 | 191 | > | |
| 192 | 192 | <MessageSquarePlus size={12} /> | |
| 193 | 193 | </button> | |
| ⋯ | |||
| 767 | 767 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 768 | 768 | return ( | |
| 769 | 769 | <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"> | |
| 771 | 771 | <span className="text-sm text-muted"> | |
| 772 | 772 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 773 | 773 | </span> | |
| ⋯ | |||
| 830 | 830 | side="left" | |
| 831 | 831 | // No keyboard popping up over the list on a phone: the filter is a tap away. | |
| 832 | 832 | 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" | |
| 834 | 834 | > | |
| 835 | 835 | <SheetHeader> | |
| 836 | 836 | <SheetTitle>Files changed</SheetTitle> | |
| 115 | 115 | function LiveEditor({ slug, pageId, role, me, mentionables, usercontent, suggestions, showComments, onPresence, renderSuggestion, onPageThreads, provider }: DocEditorProps & { provider: DocsProvider }) { | |
| 116 | 116 | const editable = canDo(role, "edit"); | |
| 117 | 117 | 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]); | |
| 118 | 123 | ||
| 119 | 124 | const threadStore = useMemo(() => { | |
| 120 | 125 | const auth = canDo(role, "comment") ? new DefaultThreadStoreAuth(me.key, editable ? "editor" : "comment") : new ReadOnlyAuth(); | |
| ⋯ | |||
| 301 | 306 | {renderSuggestion && <SuggestionRail editor={editor} suggestions={suggestions} render={renderSuggestion} hidden={showComments} />} | |
| 302 | 307 | </div> | |
| 303 | 308 | {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]"> | |
| 306 | 311 | <ThreadsSidebar filter="all" sort="position" /> | |
| 307 | 312 | </div> | |
| 308 | 313 | </aside> | |
| 311 | 311 | </fieldset> | |
| 312 | 312 | ||
| 313 | 313 | {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"> | |
| 315 | 315 | <SubmitButton pending="Saving…" busy={saving}> | |
| 316 | 316 | Save guardrails | |
| 317 | 317 | </SubmitButton> |
| 4 | 4 | ||
| 5 | 5 | import type { InboxCounts, InboxItem, InboxSeverity } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { WaitingCards } from "./notifications/card-actions"; | |
| 7 | 8 | import { SubmitButton } from "./ui"; | |
| 8 | 9 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 9 | 10 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 174 | 175 | } | |
| 175 | 176 | ||
| 176 | 177 | 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"; | |
| 178 | 179 | ||
| 179 | 180 | /** What an empty list says, for its tab. */ | |
| 180 | 181 | export function InboxEmpty({ tab, view }: { tab: InboxTab; view?: "inbox" | "saved" | "done" }) { | |
| ⋯ | |||
| 304 | 305 | <InboxTabs tab={tab} counts={counts} onChange={setTab} /> | |
| 305 | 306 | </div> | |
| 306 | 307 | <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)} /> | |
| 307 | 310 | {items == null ? ( | |
| 308 | 311 | list.state === "idle" && list.data?.tab === tab ? ( | |
| 309 | 312 | <p className="text-sm text-muted">The inbox could not be loaded. Try again in a moment.</p> | |
| 151 | 151 | : code} | |
| 152 | 152 | </code> | |
| 153 | 153 | </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"> | |
| 155 | 155 | {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>} | |
| 156 | 156 | <button | |
| 157 | 157 | type="button" |
| 114 | 114 | }, [fetcher.state, fetcher.data]); | |
| 115 | 115 | return ( | |
| 116 | 116 | <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"> | |
| 118 | 118 | <Pencil size={13} /> | |
| 119 | 119 | </DialogTrigger> | |
| 120 | 120 | <DialogContent> | |
| ⋯ | |||
| 156 | 156 | </span> | |
| 157 | 157 | )} | |
| 158 | 158 | </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> | |
| 160 | 160 | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint"> | |
| 161 | 161 | <Source memory={memory} /> | |
| 162 | 162 | <span> | |
| ⋯ | |||
| 174 | 174 | aria-label={pinned ? "Unpin" : "Pin"} | |
| 175 | 175 | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} | |
| 176 | 176 | 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" | |
| 178 | 178 | > | |
| 179 | 179 | {pinned ? <PinOff size={13} /> : <Pin size={13} />} | |
| 180 | 180 | </button> | |
| 181 | 181 | </Hint> | |
| 182 | 182 | <EditMemory memory={memory} action={action} /> | |
| 183 | 183 | <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"> | |
| 185 | 185 | <Trash2 size={13} /> | |
| 186 | 186 | </AlertDialogTrigger> | |
| 187 | 187 | <AlertDialogContent> | |
| 477 | 477 | <span | |
| 478 | 478 | className={cn( | |
| 479 | 479 | "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", | |
| 481 | 481 | )} | |
| 482 | 482 | > | |
| 483 | 483 | {total} | |
| ⋯ | |||
| 489 | 489 | ))} | |
| 490 | 490 | {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />} | |
| 491 | 491 | </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"> | |
| 493 | 493 | <p className="font-medium text-fg">{day.label}</p> | |
| 494 | 494 | <p className="mt-0.5 flex items-center gap-1.5 text-muted"> | |
| 495 | 495 | <span className="size-1.5 rounded-full bg-accent" /> {day.agents} by agents on their own | |
| 6 | 6 | BookOpen, | |
| 7 | 7 | Building2, | |
| 8 | 8 | Check, | |
| 9 | + | ChevronRight, | |
| 9 | 10 | CircleUserRound, | |
| 10 | 11 | Code2, | |
| 11 | 12 | House, | |
| ⋯ | |||
| 27 | 28 | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 28 | 29 | import { Link, useLocation, useNavigate, useSubmit } from "react-router"; | |
| 29 | 30 | ||
| 30 | − | import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 31 | + | import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 31 | 32 | ||
| 32 | 33 | import { Avatar } from "./ui"; | |
| 34 | + | import { TabStrip } from "./ui/tab-strip"; | |
| 33 | 35 | import { StatusDialog } from "./presence"; | |
| 34 | 36 | import { setPresence, useOwnPresence } from "../lib/notify-client"; | |
| 35 | 37 | import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence"; | |
| 36 | 38 | import { STATUS_URL } from "../lib/status"; | |
| 37 | 39 | 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"; | |
| 38 | 42 | ||
| 39 | 43 | /** | |
| 40 | 44 | * The phone's layout (below 768px): a tab bar along the bottom, the | |
| ⋯ | |||
| 49 | 53 | ||
| 50 | 54 | /** | |
| 51 | 55 | * 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 | |
| 53 | 58 | * `data-keyboard="open"` on <html> while the keyboard is up, so a | |
| 54 | 59 | * composer can sit just above it and the tab bar can step aside. | |
| 55 | 60 | */ | |
| ⋯ | |||
| 61 | 66 | const update = () => { | |
| 62 | 67 | root.style.setProperty("--vv-height", `${viewport.height}px`); | |
| 63 | 68 | 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`); | |
| 64 | 71 | const keyboard = window.innerHeight - viewport.height > 140; | |
| 65 | 72 | if (keyboard) root.dataset.keyboard = "open"; | |
| 66 | 73 | else delete root.dataset.keyboard; | |
| ⋯ | |||
| 118 | 125 | /** | |
| 119 | 126 | * The tab bar along the bottom of a phone: Home, Code (Docs, for a member | |
| 120 | 127 | * 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. | |
| 122 | 130 | */ | |
| 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 | + | }) { | |
| 124 | 141 | const { pathname } = useLocation(); | |
| 125 | 142 | const slug = workspace.slug; | |
| 126 | 143 | const code = hasCodeAccess(workspace); | |
| ⋯ | |||
| 157 | 174 | prefetch="intent" | |
| 158 | 175 | aria-current={current ? "page" : undefined} | |
| 159 | 176 | 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 | + | }} | |
| 160 | 184 | className="flex h-full flex-col items-center justify-center gap-0.5 transition-colors active:bg-raised/60" | |
| 161 | 185 | > | |
| 162 | 186 | <span className={`relative flex h-7 w-12 items-center justify-center rounded-full transition-colors ${current ? "bg-[#2c2c33] text-fg" : "text-muted"}`}> | |
| ⋯ | |||
| 321 | 345 | ||
| 322 | 346 | /** | |
| 323 | 347 | * 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. | |
| 326 | 351 | */ | |
| 327 | 352 | export function AvatarSheetButton({ user, workspace }: { user: User; workspace: Membership }) { | |
| 328 | 353 | const [open, setOpen] = useState(false); | |
| ⋯ | |||
| 350 | 375 | <p className="truncate font-mono text-xs text-muted">g1t.sh/{slug}</p> | |
| 351 | 376 | </div> | |
| 352 | 377 | </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 | |
| 362 | 383 | </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 | + | )} | |
| 372 | 385 | <SheetRow to={`/${slug}/-/workspace`} icon={<Building2 />}> | |
| 373 | 386 | Overview | |
| 374 | 387 | </SheetRow> | |
| ⋯ | |||
| 388 | 401 | Settings | |
| 389 | 402 | </SheetRow> | |
| 390 | 403 | </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> | |
| 391 | 419 | <SheetGroup title="Help"> | |
| 392 | 420 | <SheetRow to="/support" icon={<LifeBuoy />}> | |
| 393 | 421 | Support | |
| ⋯ | |||
| 421 | 449 | </> | |
| 422 | 450 | ); | |
| 423 | 451 | } | |
| 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 | + | } | |
| 1046 | 1046 | )} | |
| 1047 | 1047 | </Block> | |
| 1048 | 1048 | ||
| 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"> | |
| 1050 | 1050 | {editable && ( | |
| 1051 | 1051 | <SubmitButton pending="Saving…" match={{ intent: "save" }}> | |
| 1052 | 1052 | {existing ? "Save changes" : "Create ruleset"} | |
| ⋯ | |||
| 1254 | 1254 | const top = insights.by_rule[0]!.count || 1; | |
| 1255 | 1255 | return ( | |
| 1256 | 1256 | <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> | |
| 1258 | 1258 | <span className="h-2 grow overflow-hidden rounded-full bg-surface"> | |
| 1259 | 1259 | <span className="block h-full rounded-full bg-danger/70" style={{ width: `${Math.max(4, (row.count / top) * 100)}%` }} /> | |
| 1260 | 1260 | </span> | |
| 196 | 196 | <Link | |
| 197 | 197 | to={`?edit=${row.id}`} | |
| 198 | 198 | 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" | |
| 200 | 200 | > | |
| 201 | 201 | <Pencil size={14} /> | |
| 202 | 202 | </Link> | |
| ⋯ | |||
| 208 | 208 | icon | |
| 209 | 209 | match={{ intent: "delete", id: row.id }} | |
| 210 | 210 | 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" | |
| 212 | 212 | > | |
| 213 | 213 | <Trash2 size={14} /> | |
| 214 | 214 | </SubmitButton> | |
| ⋯ | |||
| 243 | 243 | return ( | |
| 244 | 244 | <div className="fixed inset-0 z-50 flex justify-end bg-black/50" role="dialog" aria-modal="true" aria-label={editing ? "Edit" : "Add"}> | |
| 245 | 245 | <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"> | |
| 247 | 247 | <div className="flex items-center justify-between border-b border-line px-6 py-4"> | |
| 248 | 248 | <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"> | |
| 250 | 250 | <X size={16} /> | |
| 251 | 251 | </Link> | |
| 252 | 252 | </div> | |
| 2071 | 2071 | /** The sidebars that sit beside the rail, one per mode that has one. */ | |
| 2072 | 2072 | type Panel = "home" | "chat" | "docs" | "agents" | "code" | "workspace" | "account"; | |
| 2073 | 2073 | ||
| 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 | + | ||
| 2074 | 2085 | /** | |
| 2075 | 2086 | * The app: a sidebar with the workspace, its repositories and the sections | |
| 2076 | 2087 | * of the one being looked at; a slim bar with search and the account; and | |
| ⋯ | |||
| 2192 | 2203 | event.preventDefault(); | |
| 2193 | 2204 | (event.currentTarget as HTMLElement | null)?.focus(); | |
| 2194 | 2205 | }} | |
| 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"}`} | |
| 2196 | 2209 | > | |
| 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>} | |
| 2200 | 2213 | </SheetContent> | |
| 2201 | 2214 | ||
| 2202 | 2215 | <div className={`flex min-h-dvh min-w-0 flex-col ${pad} ${tabs ? "pb-(--tabbar-h)" : ""}`}> | |
| 2203 | 2216 | <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" : ""}`} | |
| 2205 | 2218 | > | |
| 2206 | 2219 | {/* A phone: the workspace's avatar opens everything else (components/mobile.tsx). */} | |
| 2207 | 2220 | {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. */} | |
| 2208 | 2222 | <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" : ""}`} | |
| 2211 | 2225 | > | |
| 2212 | 2226 | <Menu size={18} /> | |
| 2213 | 2227 | </SheetTrigger> | |
| ⋯ | |||
| 2347 | 2361 | <InMain.Provider value={true}>{children}</InMain.Provider> | |
| 2348 | 2362 | </main> | |
| 2349 | 2363 | </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 | + | )} | |
| 2351 | 2371 | <CommandPalette | |
| 2352 | 2372 | open={palette} | |
| 2353 | 2373 | onOpenChange={setPalette} | |
| 125 | 125 | </div> | |
| 126 | 126 | </dl> | |
| 127 | 127 | ) : ( | |
| 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"> | |
| 129 | 129 | <div className="px-4 py-3"> | |
| 130 | 130 | <dt className="text-xs text-faint">Charged</dt> | |
| 131 | 131 | <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd> |
| 24 | 24 | ); | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 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 | + | */ | |
| 28 | 32 | 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"; | |
| 30 | 35 | ||
| 31 | 36 | export function DialogContent({ | |
| 32 | 37 | className, | |
| ⋯ | |||
| 42 | 47 | {showClose && ( | |
| 43 | 48 | <Primitive.Close | |
| 44 | 49 | 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" | |
| 46 | 51 | > | |
| 47 | 52 | <X size={16} /> | |
| 48 | 53 | </Primitive.Close> | |
| 13 | 13 | export function DropdownMenuContent({ | |
| 14 | 14 | className, | |
| 15 | 15 | sideOffset = 6, | |
| 16 | + | collisionPadding = 8, | |
| 16 | 17 | ...props | |
| 17 | 18 | }: ComponentProps<typeof Primitive.Content>) { | |
| 18 | 19 | return ( | |
| 19 | 20 | <Primitive.Portal> | |
| 20 | 21 | <Primitive.Content | |
| 21 | 22 | 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} | |
| 22 | 25 | 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", | |
| 24 | 27 | "data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none", | |
| 25 | 28 | className, | |
| 26 | 29 | )} | |
| ⋯ | |||
| 37 | 40 | return ( | |
| 38 | 41 | <Primitive.Item | |
| 39 | 42 | 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", | |
| 41 | 45 | "data-highlighted:bg-line data-highlighted:text-fg data-disabled:opacity-50", | |
| 42 | 46 | "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-faint", | |
| 43 | 47 | className, | |
| ⋯ | |||
| 82 | 86 | return ( | |
| 83 | 87 | <Primitive.SubTrigger | |
| 84 | 88 | 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", | |
| 86 | 91 | "data-highlighted:bg-line data-highlighted:text-fg data-[state=open]:bg-line data-[state=open]:text-fg data-disabled:opacity-50", | |
| 87 | 92 | "[&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-faint", | |
| 88 | 93 | className, | |
| ⋯ | |||
| 98 | 103 | export function DropdownMenuSubContent({ | |
| 99 | 104 | className, | |
| 100 | 105 | sideOffset = 6, | |
| 106 | + | collisionPadding = 8, | |
| 101 | 107 | ...props | |
| 102 | 108 | }: ComponentProps<typeof Primitive.SubContent>) { | |
| 103 | 109 | return ( | |
| 104 | 110 | <Primitive.Portal> | |
| 105 | 111 | <Primitive.SubContent | |
| 106 | 112 | sideOffset={sideOffset} | |
| 113 | + | collisionPadding={collisionPadding} | |
| 107 | 114 | 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", | |
| 109 | 116 | "data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none", | |
| 110 | 117 | className, | |
| 111 | 118 | )} | |
| 14 | 14 | className, | |
| 15 | 15 | align = "center", | |
| 16 | 16 | sideOffset = 6, | |
| 17 | + | collisionPadding = 8, | |
| 17 | 18 | ...props | |
| 18 | 19 | }: ComponentProps<typeof Primitive.Content>) { | |
| 19 | 20 | return ( | |
| ⋯ | |||
| 21 | 22 | <Primitive.Content | |
| 22 | 23 | align={align} | |
| 23 | 24 | sideOffset={sideOffset} | |
| 25 | + | // Kept inside a phone's edges. | |
| 26 | + | collisionPadding={collisionPadding} | |
| 24 | 27 | className={cn( | |
| 25 | 28 | "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", | |
| 26 | 29 | "origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none", | |
| 33 | 33 | <DialogOverlay /> | |
| 34 | 34 | <Primitive.Content | |
| 35 | 35 | 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", | |
| 37 | 38 | SIDES[side], | |
| 38 | 39 | className, | |
| 39 | 40 | )} | |
| ⋯ | |||
| 43 | 44 | {showClose && ( | |
| 44 | 45 | <Primitive.Close | |
| 45 | 46 | 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" | |
| 47 | 48 | > | |
| 48 | 49 | <X size={16} /> | |
| 49 | 50 | </Primitive.Close> | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { VISITOR_LINKS, projectPages, usesAppShell } from "./chrome.ts"; | |
| 4 | + | import { VISITOR_LINKS, projectPageAt, projectPages, usesAppShell } from "./chrome.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("someone signed in always gets the sidebar", () => { | |
| 7 | 7 | for (const path of ["/", "/pricing", "/acme/web", "/explore", "/does/not/exist"]) { | |
| ⋯ | |||
| 61 | 61 | // Anyone who can read a repository sees its deployments. | |
| 62 | 62 | assert.ok(visitor.includes("deployments") && projectPages(true).includes("deployments")); | |
| 63 | 63 | }); | |
| 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 | + | }); | |
| 107 | 107 | (page): page is ProjectPage => page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])), | |
| 108 | 108 | ); | |
| 109 | 109 | } | |
| 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 | + | } |
| 397 | 397 | {ANNOTATION_ICON[note.level]} | |
| 398 | 398 | <span className="min-w-0"> | |
| 399 | 399 | {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> | |
| 401 | 401 | {note.file && ( | |
| 402 | 402 | <span className="block font-mono text-xs text-faint"> | |
| 403 | 403 | {note.file} | |
| ⋯ | |||
| 811 | 811 | {run.error && ( | |
| 812 | 812 | <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm"> | |
| 813 | 813 | <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> | |
| 815 | 815 | </div> | |
| 816 | 816 | )} | |
| 817 | 817 | <Notes notes={notes} /> | |
| 12 | 12 | import { Pill, SoonPill } from "../../components/ui"; | |
| 13 | 13 | import { Hint } from "../../components/ui/hint"; | |
| 14 | 14 | import { TabStrip } from "../../components/ui/tab-strip"; | |
| 15 | + | import { ProjectStrip } from "../../components/mobile"; | |
| 16 | + | import { seesSettings } from "../../lib/access"; | |
| 15 | 17 | import { WatchMenu } from "../../components/notifications"; | |
| 16 | 18 | import { PinButton } from "../../components/pin-button"; | |
| 17 | 19 | import { StarButton } from "../../components/star-button"; | |
| ⋯ | |||
| 213 | 215 | /> | |
| 214 | 216 | {/* On the files' pages, About shows them. */} | |
| 215 | 217 | {!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> | |
| 216 | 222 | {tabs && ( | |
| 217 | 223 | <div className="mt-3"> | |
| 218 | 224 | <PageTabs base={base} tabs={tabs} /> | |
| 48 | 48 | return ( | |
| 49 | 49 | <li className="flex gap-2.5 px-4 py-3 text-sm"> | |
| 50 | 50 | <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> | |
| 52 | 52 | {at} | |
| 53 | 53 | </li> | |
| 54 | 54 | ); |
| 181 | 181 | </RadioGroup> | |
| 182 | 182 | </Section> | |
| 183 | 183 | ||
| 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"> | |
| 185 | 185 | <SubmitButton pending="Saving…">Save settings</SubmitButton> | |
| 186 | 186 | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 187 | 187 | <ErrorText>{actionData?.error}</ErrorText> |
| 192 | 192 | </Link> | |
| 193 | 193 | </Section> | |
| 194 | 194 | ||
| 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"> | |
| 196 | 196 | <SubmitButton pending="Saving…" disabled={archived}> | |
| 197 | 197 | Save settings | |
| 198 | 198 | </SubmitButton> |
| 364 | 364 | /> | |
| 365 | 365 | </Section> | |
| 366 | 366 | ||
| 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"> | |
| 368 | 368 | <SubmitButton name="intent" value="save" pending="Saving…"> | |
| 369 | 369 | {environment.protected ? "Save rules" : "Add rules"} | |
| 370 | 370 | </SubmitButton> |
| 97 | 97 | </fieldset> | |
| 98 | 98 | ||
| 99 | 99 | {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"> | |
| 101 | 101 | <SubmitButton pending="Saving…">Save settings</SubmitButton> | |
| 102 | 102 | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 103 | 103 | <ErrorText>{actionData?.error}</ErrorText> |
| 203 | 203 | <input type="hidden" name="id" value={memory.id} /> | |
| 204 | 204 | <input type="hidden" name="pinned" value={pinned ? "false" : "true"} /> | |
| 205 | 205 | <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"> | |
| 207 | 207 | {pinned ? <PinOff size={14} /> : <Pin size={14} />} | |
| 208 | 208 | </button> | |
| 209 | 209 | </Hint> | |
| 210 | 210 | </pin.Form> | |
| 211 | 211 | <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"> | |
| 213 | 213 | <Pencil size={14} /> | |
| 214 | 214 | </button> | |
| 215 | 215 | </Hint> | |
| ⋯ | |||
| 219 | 219 | fields={{ intent: "forget", id: memory.id }} | |
| 220 | 220 | fetcherKey={`forget-${memory.id}`} | |
| 221 | 221 | 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"> | |
| 223 | 223 | <Trash2 size={14} /> | |
| 224 | 224 | </button> | |
| 225 | 225 | } | |
| 279 | 279 | </Rail> | |
| 280 | 280 | <div className="min-w-0 grow rounded-lg border border-accent/30 bg-accent/[0.06] px-3.5 py-2.5"> | |
| 281 | 281 | <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> | |
| 283 | 283 | </div> | |
| 284 | 284 | </li> | |
| 285 | 285 | ); | |
| ⋯ | |||
| 294 | 294 | <span className="font-medium text-fg">{event.by ? `@${event.by}` : "g1t"}</span> | |
| 295 | 295 | {when} | |
| 296 | 296 | </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> | |
| 298 | 298 | </div> | |
| 299 | 299 | </li> | |
| 300 | 300 | ); | |
| ⋯ | |||
| 341 | 341 | <p className="flex items-center gap-2 text-xs text-info"> | |
| 342 | 342 | <span className="font-medium">{event.by ? `@${event.by}` : "Someone"}</span> steered it{when} | |
| 343 | 343 | </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> | |
| 345 | 345 | </div> | |
| 346 | 346 | </li> | |
| 347 | 347 | ); | |
| ⋯ | |||
| 353 | 353 | </Rail> | |
| 354 | 354 | <div className="min-w-0 grow"> | |
| 355 | 355 | <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> | |
| 357 | 357 | </div> | |
| 358 | 358 | </li> | |
| 359 | 359 | ); | |
| ⋯ | |||
| 367 | 367 | <p className="flex items-center gap-2 text-xs text-muted"> | |
| 368 | 368 | {event.by ? <span className="font-medium text-fg">@{event.by}</span> : null} reported back{when} | |
| 369 | 369 | </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> | |
| 371 | 371 | </div> | |
| 372 | 372 | </li> | |
| 373 | 373 | ); | |
| 213 | 213 | <div className="group relative -mx-4 h-44 sm:-mx-6 lg:-mx-8" style={{ background: cover }}> | |
| 214 | 214 | {editable && ( | |
| 215 | 215 | <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"> | |
| 217 | 217 | Change cover | |
| 218 | 218 | </button> | |
| 219 | 219 | </CoverPicker> | |
| ⋯ | |||
| 221 | 221 | </div> | |
| 222 | 222 | )} | |
| 223 | 223 | {/* 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"> | |
| 225 | 225 | <div className="min-w-0 grow"> | |
| 226 | 226 | <Crumbs items={crumbs} /> | |
| 227 | 227 | </div> | |
| ⋯ | |||
| 245 | 245 | </span> | |
| 246 | 246 | )} | |
| 247 | 247 | <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"}`}> | |
| 249 | 249 | <MessageSquare size={16} /> | |
| 250 | 250 | </button> | |
| 251 | 251 | </Hint> | |
| 208 | 208 | <button | |
| 209 | 209 | type="button" | |
| 210 | 210 | 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" | |
| 212 | 212 | > | |
| 213 | 213 | <Ellipsis size={16} /> | |
| 214 | 214 | </button> |