Commit

A Vercel-like sidebar and a new mark

The sidebar leads with the mark and the workspace, whose name links to it and whose arrows alone switch it, on a row ruled flush with the top bar. Find sits under it; a usage meter and the account sit at the bottom. The top bar is a breadcrumb with Docs and New. The mark is now an isometric cube in three tones, the unit of work from g1t's artwork, with a heavier sans wordmark.

syntaqxcommitted Parent18343d4Browse files
6 files+229−1370/6 viewed
+7−6
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke-width="2.5" stroke-linecap="round">
2− <rect width="32" height="32" rx="7" fill="#12110d"/>
3− <path d="M4 7h6c7 0 7 9 14 9" stroke="#8f8c82"/>
4− <path d="M4 25h6c7 0 7-9 14-9" stroke="#8f8c82"/>
5− <path d="M4 16h20" stroke="#86efc4"/>
6− <circle cx="25" cy="16" r="3.5" fill="#86efc4"/>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect width="32" height="32" rx="7" fill="#161618"/>
3+ <g transform="translate(4.5 4.6) scale(0.72)">
4+ <path d="M16 2.5 28 9.4 16 16.3 4 9.4Z" fill="#ededef"/>
5+ <path d="M4 10.9 15.3 17.4V30.2L4 23.7Z" fill="#ededef" fill-opacity="0.55"/>
6+ <path d="M28 10.9 16.7 17.4V30.2L28 23.7Z" fill="#ededef" fill-opacity="0.25"/>
7+ </g>
78 </svg>
+14−17
1−/** Three lanes converging on one node: many pull requests, one merged. */
1+/**
2+ * g1t's mark: an isometric cube, the unit of work in its artwork. Three
3+ * faces in three tones of the text colour, split by hairline gaps, so it
4+ * holds at any size and on any background.
5+ */
26 export function Mark({ className }: { className?: string }) {
37 return (
4− <svg
5− viewBox="0 0 32 32"
6− fill="none"
7− strokeWidth="2.5"
8− strokeLinecap="round"
9− className={className}
10− aria-hidden="true"
11− >
12− <path d="M4 7h6c7 0 7 9 14 9" className="stroke-muted" />
13− <path d="M4 25h6c7 0 7-9 14-9" className="stroke-muted" />
14− <path d="M4 16h20" className="stroke-accent" />
15− <circle cx="25" cy="16" r="3.5" className="fill-accent" stroke="none" />
8+ <svg viewBox="0 0 32 32" className={className} aria-hidden="true">
9+ {/* Top face */}
10+ <path d="M16 2.5 28 9.4 16 16.3 4 9.4Z" fill="currentColor" />
11+ {/* Left face */}
12+ <path d="M4 10.9 15.3 17.4V30.2L4 23.7Z" fill="currentColor" fillOpacity="0.55" />
13+ {/* Right face */}
14+ <path d="M28 10.9 16.7 17.4V30.2L28 23.7Z" fill="currentColor" fillOpacity="0.25" />
1615 </svg>
1716 );
1817 }
1918
2019 export function Logo() {
2120 return (
22− <span className="inline-flex items-center gap-2">
21+ <span className="inline-flex items-center gap-2 text-fg">
2322 <Mark className="size-6" />
24− <span className="font-mono text-lg font-semibold tracking-tight">
25− g<span className="text-accent">1</span>t
26− </span>
23+ <span className="text-[1.15rem] leading-none font-bold tracking-[-0.035em]">g1t</span>
2724 </span>
2825 );
2926 }
+190−97
44 BookOpen,
55 Check,
66 ChevronsUpDown,
7+ Ellipsis,
78 CircleDot,
89 Code2,
910 Compass,
3031 import type { User } from "@g1t/contracts";
3132 import { MICROS_PER_DOLLAR } from "@g1t/contracts";
3233
33−import { Logo } from "./logo";
34+import { Mark } from "./logo";
3435 import { Avatar } from "./ui";
3536 import {
3637 DropdownMenu,
8586 prefetch="intent"
8687 className={({ isActive, isPending }) => {
8788 const current = isActive || (also != null && pathname.startsWith(also + "/"));
88− return `group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors ${
89+ return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
8990 current
9091 ? "bg-raised font-medium text-fg"
9192 : isPending
107108
108109 function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
109110 return (
110− <section className="mt-5">
111− <div className="mb-1 flex items-center justify-between px-2">
112− <h2 className="text-[0.6875rem] font-medium tracking-wider text-faint uppercase">
113− {title}
114− </h2>
111+ <section className="mt-6">
112+ <div className="mb-1 flex h-6 items-center justify-between px-2">
113+ <h2 className="text-xs font-medium text-faint">{title}</h2>
115114 {action}
116115 </div>
117116 <div className="space-y-px">{children}</div>
123122 const current = shell.workspace?.slug;
124123 return (
125124 <DropdownMenu>
126− <DropdownMenuTrigger className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
125+ {/* The name goes to the workspace; only the arrows switch it. */}
126+ <Link
127+ to={current ? `/${current}` : "/workspaces/new"}
128+ className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
129+ >
127130 {current ? (
128− <Avatar name={current} size={26} square />
131+ <Avatar name={current} size={20} square />
129132 ) : (
130− <span className="size-6.5 rounded-md border border-dashed border-line-strong" />
133+ <span className="size-5 rounded-md border border-dashed border-line-strong" />
131134 )}
132− <span className="min-w-0 grow">
133− <span className="block truncate text-sm font-semibold">{current ?? "No workspace"}</span>
134− <span className="block truncate text-xs text-faint">
135− {shell.workspace?.role ?? "Create one to start"}
135+ <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
136+ {shell.workspace && (
137+ <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
138+ {shell.workspace.role}
136139 </span>
137− </span>
138− <ChevronsUpDown size={14} className="shrink-0 text-faint" />
140+ )}
141+ </Link>
142+ <DropdownMenuTrigger
143+ aria-label="Switch workspace"
144+ className="ml-auto flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
145+ >
146+ <ChevronsUpDown size={14} />
139147 </DropdownMenuTrigger>
140148 <DropdownMenuContent align="start" className="w-60">
141149 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
160168 );
161169 }
162170
163−function Sidebar({ user, shell }: { user: User; shell: ShellData }) {
171+/** This month's spend against what is left, as Vercel shows a plan's usage. */
172+function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
173+ if (shell.monthSpentMicros == null) return null;
174+ const spent = shell.monthSpentMicros;
175+ const left = shell.creditMicros;
176+ const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
177+ return (
178+ <Link
179+ to={`/${slug}/-/usage`}
180+ className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
181+ >
182+ <span className="flex items-baseline justify-between text-xs">
183+ <span className="font-medium text-fg">Usage</span>
184+ <span className="text-faint">this month</span>
185+ </span>
186+ <span className="mt-2 flex items-baseline justify-between">
187+ <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
188+ {left != null && (
189+ <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
190+ ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
191+ </span>
192+ )}
193+ </span>
194+ <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
195+ <span
196+ className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
197+ style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
198+ />
199+ </span>
200+ </Link>
201+ );
202+}
203+
204+function AccountMenu({ user }: { user: User }) {
205+ const submit = useSubmit();
206+ return (
207+ <DropdownMenu>
208+ <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
209+ <Avatar name={user.username} size={22} />
210+ <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
211+ <Ellipsis size={15} className="shrink-0 text-faint" />
212+ </DropdownMenuTrigger>
213+ <DropdownMenuContent align="start" side="top" className="w-56">
214+ <DropdownMenuLabel>
215+ Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
216+ </DropdownMenuLabel>
217+ <DropdownMenuSeparator />
218+ <DropdownMenuItem asChild>
219+ <Link to="/settings">
220+ <Settings />
221+ Your settings
222+ </Link>
223+ </DropdownMenuItem>
224+ <DropdownMenuItem asChild>
225+ <a href="https://docs.g1t.sh/">
226+ <BookOpen />
227+ Documentation
228+ </a>
229+ </DropdownMenuItem>
230+ <DropdownMenuSeparator />
231+ {/* Submitted from here: the menu closes on select, and a button
232+ that has left the page cannot submit a form. */}
233+ <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
234+ <LogOut />
235+ Sign out
236+ </DropdownMenuItem>
237+ </DropdownMenuContent>
238+ </DropdownMenu>
239+ );
240+}
241+
242+function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
164243 const ws = shell.workspace;
165244 const active = shell.repo;
166245 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
171250 : shell.repos;
172251 return (
173252 <div className="flex h-full flex-col">
174− <div className="flex h-12 items-center gap-2 px-4">
175− <Link to="/" aria-label="g1t home">
176− <Logo />
253+ {/* The same height and rule as the top bar, so the two read as one line. */}
254+ <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
255+ <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
256+ <Mark className="size-5" />
177257 </Link>
258+ <span className="shrink-0 text-line-strong" aria-hidden="true">
259+ /
260+ </span>
261+ <WorkspaceSwitcher user={user} shell={shell} />
178262 </div>
179− <div className="px-2">
180− <WorkspaceSwitcher user={user} shell={shell} />
263+ <div className="px-2 pt-3">
264+ <button
265+ type="button"
266+ onClick={onFind}
267+ className="flex h-8 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
268+ >
269+ <Search size={14} />
270+ <span className="grow text-left">Find…</span>
271+ <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
272+ </button>
181273 </div>
182274 <nav className="min-h-0 grow overflow-y-auto px-2 pb-4">
183275 <div className="mt-3 space-y-px">
297389 </SidebarGroup>
298390 </nav>
299391
300− <div className="border-t border-line p-2">
301− {ws && shell.monthSpentMicros != null && (
392+ <div className="space-y-2 p-2">
393+ {ws && <UsageCard slug={ws.slug} shell={shell} />}
394+ <AccountMenu user={user} />
395+ </div>
396+ </div>
397+ );
398+}
399+
400+/** Words for the sections a path can end in. */
401+const SECTIONS: Record<string, string> = {
402+ issues: "Issues",
403+ pulls: "Pull requests",
404+ queue: "Merge queue",
405+ commits: "Commits",
406+ plans: "Plan",
407+ settings: "Settings",
408+ people: "People",
409+ tokens: "Access tokens",
410+ usage: "Usage",
411+ billing: "Billing",
412+ tree: "Code",
413+ blob: "Code",
414+};
415+
416+/** Where the page is, as a trail of links: workspace / repository / section. */
417+function Breadcrumbs({ pathname }: { pathname: string }) {
418+ const parts = pathname.split("/").filter(Boolean);
419+ const reserved = ["settings", "explore", "new", "search", "workspaces"];
420+ if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
421+ if (reserved.includes(parts[0]!)) {
422+ const words: Record<string, string> = {
423+ settings: "Your settings",
424+ explore: "Explore",
425+ new: "New repository",
426+ search: "Search",
427+ workspaces: "New workspace",
428+ };
429+ return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
430+ }
431+ const [owner, second, third, fourth] = parts;
432+ const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
433+ if (second === "-") {
434+ if (third) trail.push({ label: SECTIONS[third] ?? third, to: `/${owner}/-/${third}` });
435+ } else if (second) {
436+ const repo = `/${owner}/${second}`;
437+ trail.push({ label: second, to: repo, mono: true });
438+ if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
439+ else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
440+ else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
441+ else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
442+ }
443+ return (
444+ <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
445+ {trail.map((crumb, index) => (
446+ <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
447+ {index > 0 && <span className="text-line-strong">/</span>}
302448 <Link
303− to={`/${ws.slug}/-/usage`}
304− className="mb-1 block rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg"
449+ to={crumb.to}
450+ className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
451+ index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
452+ } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
305453 >
306− <span className="flex items-center gap-2.5">
307− <BarChart3 size={15} className="shrink-0 text-faint" />
308− <span className="grow">Usage this month</span>
309− <span className="font-mono text-xs text-fg tabular-nums">
310− ${(shell.monthSpentMicros / MICROS_PER_DOLLAR).toFixed(2)}
311− </span>
312− </span>
313− {shell.creditMicros != null && (
314− <span
315− className={`mt-0.5 block pl-6.5 text-xs ${shell.creditMicros <= 0 ? "text-warn" : "text-faint"}`}
316− >
317− ${(shell.creditMicros / MICROS_PER_DOLLAR).toFixed(2)} of credit left
318− </span>
319− )}
454+ {crumb.label}
320455 </Link>
321− )}
322− <a
323− href="https://docs.g1t.sh/"
324− className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg"
325− >
326− <BookOpen size={15} className="shrink-0 text-faint" />
327− Documentation
328− </a>
329− </div>
330− </div>
456+ </span>
457+ ))}
458+ </nav>
331459 );
332460 }
333461
557685 <div className="min-h-screen">
558686 <Progress />
559687 <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
560− <Sidebar user={user} shell={shell} />
688+ <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
561689 </aside>
562690 {drawer && (
563691 <div className="fixed inset-0 z-50 lg:hidden">
576704 >
577705 <X size={16} />
578706 </button>
579− <Sidebar user={user} shell={shell} />
707+ <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
580708 </aside>
581709 </div>
582710 )}
591719 >
592720 <Menu size={18} />
593721 </button>
594− <button
595− type="button"
596− onClick={() => setPalette(true)}
597− className="flex h-8 w-full max-w-md items-center gap-2 rounded-md border border-line bg-surface/60 px-2.5 text-sm text-faint transition-colors hover:border-line-strong hover:text-muted"
598− >
599− <Search size={14} />
600− <span className="grow text-left">Search or jump to…</span>
601− <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.6875rem] sm:inline">⌘K</kbd>
602− </button>
603− <Form action="/search" className="hidden" />
722+ <Breadcrumbs pathname={pathname} />
604723 <div className="ml-auto flex items-center gap-1.5">
724+ <a
725+ href="https://docs.g1t.sh/"
726+ className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
727+ >
728+ Docs
729+ </a>
605730 <DropdownMenu>
606731 <DropdownMenuTrigger
607732 aria-label="Create"
608− className="rounded-md border border-line p-1.5 text-muted outline-none transition-colors hover:border-line-strong hover:text-fg data-[state=open]:bg-raised"
733+ className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white"
609734 >
610− <Plus size={15} />
735+ <Plus size={14} />
736+ New
611737 </DropdownMenuTrigger>
612738 <DropdownMenuContent align="end">
613739 {shell.repo && (
637763 <Users />
638764 New workspace
639765 </Link>
640− </DropdownMenuItem>
641− </DropdownMenuContent>
642− </DropdownMenu>
643− <DropdownMenu>
644− <DropdownMenuTrigger
645− aria-label="Account menu"
646− className="rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"
647− >
648− <Avatar name={user.username} size={24} />
649− </DropdownMenuTrigger>
650− <DropdownMenuContent align="end" className="w-56">
651− <DropdownMenuLabel>
652− Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
653− </DropdownMenuLabel>
654− <DropdownMenuSeparator />
655− <DropdownMenuItem asChild>
656− <Link to="/settings">
657− <Settings />
658− Your settings
659− </Link>
660− </DropdownMenuItem>
661− <DropdownMenuItem asChild>
662− <a href="https://docs.g1t.sh/">
663− <BookOpen />
664− Documentation
665− </a>
666− </DropdownMenuItem>
667− <DropdownMenuSeparator />
668− {/* Submitted from here: the menu closes on select, and a button
669− that has left the page cannot submit a form. */}
670− <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
671− <LogOut />
672− Sign out
673766 </DropdownMenuItem>
674767 </DropdownMenuContent>
675768 </DropdownMenu>
+7−6
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke-width="2.5" stroke-linecap="round">
2− <rect width="32" height="32" rx="7" fill="#12110d"/>
3− <path d="M4 7h6c7 0 7 9 14 9" stroke="#8f8c82"/>
4− <path d="M4 25h6c7 0 7-9 14-9" stroke="#8f8c82"/>
5− <path d="M4 16h20" stroke="#86efc4"/>
6− <circle cx="25" cy="16" r="3.5" fill="#86efc4"/>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect width="32" height="32" rx="7" fill="#161618"/>
3+ <g transform="translate(4.5 4.6) scale(0.72)">
4+ <path d="M16 2.5 28 9.4 16 16.3 4 9.4Z" fill="#ededef"/>
5+ <path d="M4 10.9 15.3 17.4V30.2L4 23.7Z" fill="#ededef" fill-opacity="0.55"/>
6+ <path d="M28 10.9 16.7 17.4V30.2L28 23.7Z" fill="#ededef" fill-opacity="0.25"/>
7+ </g>
78 </svg>
+7−6
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke-width="2.5" stroke-linecap="round">
2− <rect width="32" height="32" rx="7" fill="#12110d"/>
3− <path d="M4 7h6c7 0 7 9 14 9" stroke="#8f8c82"/>
4− <path d="M4 25h6c7 0 7-9 14-9" stroke="#8f8c82"/>
5− <path d="M4 16h20" stroke="#b6f24a"/>
6− <circle cx="25" cy="16" r="3.5" fill="#b6f24a"/>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect width="32" height="32" rx="7" fill="#161618"/>
3+ <g transform="translate(4.5 4.6) scale(0.72)">
4+ <path d="M16 2.5 28 9.4 16 16.3 4 9.4Z" fill="#ededef"/>
5+ <path d="M4 10.9 15.3 17.4V30.2L4 23.7Z" fill="#ededef" fill-opacity="0.55"/>
6+ <path d="M28 10.9 16.7 17.4V30.2L28 23.7Z" fill="#ededef" fill-opacity="0.25"/>
7+ </g>
78 </svg>
+4−5
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke-width="2.5" stroke-linecap="round">
2− <path d="M4 7h6c7 0 7 9 14 9" stroke="#a09c8f"/>
3− <path d="M4 25h6c7 0 7-9 14-9" stroke="#a09c8f"/>
4− <path d="M4 16h20" stroke="#b6f24a"/>
5− <circle cx="25" cy="16" r="3.5" fill="#b6f24a"/>
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <path d="M16 2.5 28 9.4 16 16.3 4 9.4Z" fill="#ededef"/>
3+ <path d="M4 10.9 15.3 17.4V30.2L4 23.7Z" fill="#ededef" fill-opacity="0.55"/>
4+ <path d="M28 10.9 16.7 17.4V30.2L28 23.7Z" fill="#ededef" fill-opacity="0.25"/>
65 </svg>