Skip to content

Commit

The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.

syntaqxcommitted Parente9688c5Browse files
35 files+892−2170/35 viewed
+13−0
298298 address on its own becomes a link, and links open in a new tab. Headings
299299 an agent writes show as a bold line, and a rule as a line across.
300300
301+A table written in Markdown shows as a table: a header row, a row of
302+`---` under it (`:--`, `:-:` or `--:` to line a column up left, centre
303+or right), then a row per line. A wide table scrolls sideways inside the
304+message. The composer has no table to edit, so editing a message with
305+one shows its rows as the lines they were written as.
306+
307+```md
308+| Session | Length | Who |
309+| --- | --: | --- |
310+| Welcome and tour | 45 min | Ana |
311+| Your first issue | 60 min | Margo |
312+```
313+
301314 Chat never shows HTML: `<b>` reads as typed. An image written in Markdown
302315 shows as a link to it, not the image.
303316
+100−0
274274 :root {
275275 accent-color: var(--color-accent);
276276 }
277+
278+ /*
279+ * Number fields have no spin buttons: the arrow keys still step them, and
280+ * their digits line up in columns.
281+ */
282+ input[type="number"] {
283+ -moz-appearance: textfield;
284+ appearance: textfield;
285+ font-variant-numeric: tabular-nums;
286+ }
287+ input[type="number"]::-webkit-inner-spin-button,
288+ input[type="number"]::-webkit-outer-spin-button {
289+ -webkit-appearance: none;
290+ appearance: none;
291+ margin: 0;
292+ }
293+
294+ /*
295+ * Date and time fields: the picker's icon is drawn by the browser in the
296+ * page's color-scheme (set on <html> above), so it is right in both
297+ * themes; it sits quiet until hovered. The part being edited is marked
298+ * in the accent, not the system's blue.
299+ */
300+ input:is([type="date"], [type="time"], [type="datetime-local"], [type="month"], [type="week"]) {
301+ font-variant-numeric: tabular-nums;
302+ }
303+ ::-webkit-calendar-picker-indicator {
304+ cursor: pointer;
305+ border-radius: 0.25rem;
306+ opacity: 0.55;
307+ transition: opacity 150ms;
308+ }
309+ ::-webkit-calendar-picker-indicator:hover,
310+ ::-webkit-calendar-picker-indicator:focus-visible {
311+ opacity: 1;
312+ }
313+ ::-webkit-datetime-edit-fields-wrapper {
314+ padding: 0;
315+ }
316+ ::-webkit-datetime-edit-text {
317+ color: var(--color-faint);
318+ }
319+ ::-webkit-datetime-edit-year-field:focus,
320+ ::-webkit-datetime-edit-month-field:focus,
321+ ::-webkit-datetime-edit-day-field:focus,
322+ ::-webkit-datetime-edit-hour-field:focus,
323+ ::-webkit-datetime-edit-minute-field:focus,
324+ ::-webkit-datetime-edit-second-field:focus,
325+ ::-webkit-datetime-edit-millisecond-field:focus,
326+ ::-webkit-datetime-edit-meridiem-field:focus {
327+ border-radius: 0.1875rem;
328+ background-color: color-mix(in srgb, var(--color-accent) 25%, transparent);
329+ color: var(--color-fg);
330+ }
331+
332+ /* A colour field is a rounded swatch with a hairline, not a grey button. */
333+ input[type="color"] {
334+ -webkit-appearance: none;
335+ appearance: none;
336+ cursor: pointer;
337+ padding: 0.1875rem;
338+ border: 1px solid var(--color-line);
339+ border-radius: 0.375rem;
340+ background-color: var(--color-bg);
341+ }
342+ input[type="color"]:hover {
343+ border-color: var(--color-line-strong);
344+ }
345+ input[type="color"]::-webkit-color-swatch-wrapper {
346+ padding: 0;
347+ }
348+ input[type="color"]::-webkit-color-swatch {
349+ border: none;
350+ border-radius: 0.25rem;
351+ }
352+ input[type="color"]::-moz-color-swatch {
353+ border: none;
354+ border-radius: 0.25rem;
355+ }
356+
357+ /* A search field's clear button is the browser's own: g1t's fields don't show it. */
358+ input[type="search"]::-webkit-search-cancel-button,
359+ input[type="search"]::-webkit-search-decoration {
360+ -webkit-appearance: none;
361+ appearance: none;
362+ }
363+
364+ /*
365+ * A <summary> that keeps the browser's disclosure triangle has it quiet,
366+ * in the faint text colour; one that draws its own hides it with list-none.
367+ */
368+ summary {
369+ cursor: pointer;
370+ }
371+ summary::marker {
372+ color: var(--color-faint);
373+ }
374+ summary::-webkit-details-marker {
375+ color: var(--color-faint);
376+ }
277377 }
278378
279379 @media (prefers-reduced-motion: reduce) {
+2−2
44 import type { AgentRouting, ModelTier, SubagentDef } from "@g1t/contracts";
55
66 import { Hint } from "./ui/hint";
7+import { Input } from "./ui/input";
78 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
89 import { Sheet, SheetContent, SheetDescription, SheetTitle } from "./ui/sheet";
910 import { MAX_RESPONSIBILITIES, TIER_LABELS, clampRouting, cleanSubagentName } from "../lib/agent-form";
326327 </div>
327328 <label className="block max-w-40">
328329 <span className="mb-1.5 block text-sm font-medium text-fg-soft">At once, in one task</span>
329− <input
330+ <Input
330331 type="number"
331332 min={1}
332333 max={8}
334335 onChange={(event) =>
335336 setEditing({ ...editing, draft: { ...editing.draft, max_parallel: Math.max(1, Math.min(8, Number(event.target.value) || 1)) } })
336337 }
337− className={`${FIELD} tabular-nums`}
338338 />
339339 </label>
340340 </div>
+5−3
1010 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
1111 import { Skeleton } from "./ui/skeleton";
1212 import { AUTONOMY, type AgentDraft, PRESETS, TIER_LABELS, cleanHandle, dollarsField } from "../lib/agent-form";
13+import { Checkbox } from "./ui/checkbox";
1314 import { Hint } from "./ui/hint";
15+import { Input } from "./ui/input";
1416 import { EffortPicker } from "./effort";
1517 import { effortSetting } from "../lib/effort";
1618 import { DEPARTMENTS, RoleFields, SubagentsField } from "./agent-role";
633635 {spaces.map((space) => (
634636 <label
635637 key={space.id}
636− className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 text-sm has-[:checked]:border-accent/50 has-[:checked]:bg-accent/5"
638+ className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 text-sm has-[[data-state=checked]]:border-accent/50 has-[[data-state=checked]]:bg-accent/5"
637639 >
638− <input type="checkbox" name="reading" value={space.id} defaultChecked={(draft.reading ?? []).includes(space.id)} className="size-4 accent-[var(--color-accent)]" />
640+ <Checkbox name="reading" value={space.id} defaultChecked={(draft.reading ?? []).includes(space.id)} />
639641 <span className="min-w-0 grow truncate">{space.name}</span>
640642 <span className="shrink-0 text-xs text-faint capitalize">{space.kind}</span>
641643 </label>
798800 <Label htmlFor="capacity" error={e.capacity}>
799801 Sessions at once
800802 </Label>
801− <input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} className={`${FIELD} tabular-nums`} />
803+ <Input id="capacity" name="capacity" type="number" min={1} max={20} defaultValue={draft.capacity} />
802804 </div>
803805 </FormSection>
804806
+4−5
1313 import { AgentAvatar, PixelCreature } from "../agent-avatar";
1414 import { Markdown } from "../markdown";
1515 import { Badge } from "../ui/badge";
16+import { Checkbox } from "../ui/checkbox";
1617 import { Hint } from "../ui/hint";
1718 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
1819 import { type BuilderDefinition, type ChangeRow, changeRows, integrationHint } from "../../lib/agent-builder";
369370 return (
370371 <label
371372 key={skill.id}
372− className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm has-checked:border-accent/50 has-checked:bg-accent/5"
373+ className="flex min-h-10 cursor-pointer items-center gap-2.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm has-[[data-state=checked]]:border-accent/50 has-[[data-state=checked]]:bg-accent/5"
373374 >
374− <input
375− type="checkbox"
375+ <Checkbox
376376 checked={on}
377− onChange={() => {
377+ onCheckedChange={() => {
378378 const next = new Set(off);
379379 if (on) next.add(skill.id);
380380 else next.delete(skill.id);
381381 set({ skills_off: FOUNDATIONAL_SKILLS.map((s) => s.id).filter((id) => next.has(id)) });
382382 }}
383− className="size-4 accent-(--color-accent)"
384383 />
385384 <span className="min-w-0 grow truncate">{skill.name}</span>
386385 {suggested.has(skill.id) && suggested.size < FOUNDATIONAL_SKILLS.length && (
+1−1
3939 <Form method="post" encType="multipart/form-data" ref={form}>
4040 <input type="hidden" name="intent" value="avatar" />
4141 <label
42− className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong ${
42+ className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent ${
4343 busy ? "pointer-events-none opacity-50" : ""
4444 }`}
4545 >
+7−6
1616 import { money } from "../lib/usage";
1717 import { Card } from "./billing";
1818 import { ErrorText, SubmitButton } from "./ui";
19+import { Checkbox } from "./ui/checkbox";
1920 import { SelectField } from "./ui/select";
2021 import { Skeleton } from "./ui/skeleton";
2122
329330 {owner ? (
330331 <Form method="post" className="mt-3 space-y-3 text-sm">
331332 <input type="hidden" name="intent" value="ai-reload" />
332− <label className="flex items-center gap-2">
333− <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--color-accent)]" />
333+ <label className="flex cursor-pointer items-center gap-2">
334+ <Checkbox name="enabled" defaultChecked={r.enabled} />
334335 Reload from the saved card
335336 </label>
336337 <div className="flex flex-wrap items-center gap-2 text-muted">
387388 <fieldset className="flex flex-wrap items-center gap-3">
388389 <legend className="mb-1.5 text-xs text-muted">Alert at</legend>
389390 {[50, 75, 90, 100].map((level) => (
390− <label key={level} className="flex items-center gap-1.5">
391− <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--color-accent)]" />
391+ <label key={level} className="flex cursor-pointer items-center gap-1.5 tabular-nums">
392+ <Checkbox name="alert" value={String(level)} defaultChecked={levels.includes(level)} />
392393 {level}%
393394 </label>
394395 ))}
395396 </fieldset>
396− <label className="flex items-center gap-2">
397− <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--color-accent)]" />
397+ <label className="flex cursor-pointer flex-wrap items-center gap-2">
398+ <Checkbox name="pause" defaultChecked={pause} />
398399 Pause usage at 100%
399400 <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span>
400401 </label>
+13−30
6060 } from "../ui/alert-dialog";
6161 import { Badge } from "../ui/badge";
6262 import { Hint } from "../ui/hint";
63+import { Button } from "../ui/button";
6364 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
6465 import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat";
6566 import { codeAccessPath } from "../../lib/workspace-nav";
822823 const channel = data.channel;
823824 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
824825 return (
825− <header className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
826− <button
827− type="button"
828− onClick={onBack}
829− aria-label="Back to conversations"
830− className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden"
831− >
826+ <header data-page-head className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
827+ <Button variant="ghost" size="icon-bar" onClick={onBack} aria-label="Back to conversations" className="rounded-full md:hidden">
832828 <ChevronLeft size={22} />
833− </button>
829+ </Button>
834830 <div className="flex min-w-0 grow items-center gap-2.5">
835831 {channel.kind === "channel" ? (
836832 <>
899895 )}
900896 {channel.kind === "channel" && (
901897 <Hint label={info ? "Hide details" : "Members and details"}>
902− <button
903− type="button"
904− onClick={onInfo}
905− aria-pressed={info}
906− aria-label="Members and details"
907− className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${
908− info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg"
909− }`}
910− >
898+ <Button variant="outline" size="sm" onClick={onInfo} aria-pressed={info} aria-label="Members and details" className="px-2 text-xs text-muted tabular-nums aria-pressed:border-line-strong aria-pressed:bg-raised aria-pressed:text-fg max-md:h-11">
911899 <UsersStack count={memberCount} />
912− </button>
900+ </Button>
913901 </Hint>
914902 )}
915903 {channel.kind === "dm" && (
916904 <Hint label={info ? "Hide details" : "Details"}>
917− <button
918− type="button"
919− onClick={onInfo}
920− aria-pressed={info}
921− aria-label="Details"
922− className={`flex size-8 items-center justify-center rounded-md transition-colors ${info ? "bg-raised text-fg" : "text-muted hover:bg-raised hover:text-fg"}`}
923− >
905+ <Button variant="ghost" size="icon-bar" onClick={onInfo} aria-pressed={info} aria-label="Details">
924906 <PanelRight size={16} />
925− </button>
907+ </Button>
926908 </Hint>
927909 )}
928910 </header>
941923 function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
942924 return (
943925 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
944− <button
945− type="button"
926+ <Button
927+ variant="ghost"
928+ size="icon-sm"
946929 aria-label={starred ? "Unstar" : "Star"}
947930 aria-pressed={starred}
948931 onClick={onClick}
949− className={`flex size-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised ${starred ? "text-warn" : "text-faint hover:text-fg"}`}
932+ className="text-faint aria-pressed:bg-transparent aria-pressed:text-warn aria-pressed:hover:bg-raised max-md:size-10"
950933 >
951934 <Star size={15} fill={starred ? "currentColor" : "none"} />
952− </button>
935+ </Button>
953936 </Hint>
954937 );
955938 }
+39−2
44
55 import { MemberCard } from "./profile-card";
66 import { Hint } from "../ui/hint";
7+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
78 import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat";
89 // The workspace's own emoji, drawn where `:name:` is written (components/emoji).
910 import { useEmojiContext } from "../emoji/context";
142143 case "p":
143144 return <p>{lines(block.lines, context)}</p>;
144145 case "heading":
145− // Chat, not a document: a heading is a bold line, the top two a touch larger.
146− return <p className={`font-semibold text-fg ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>;
146+ // Chat, not a document: a heading is a bold line with room above it, the top two a touch larger.
147+ return <p className={`pt-1.5 font-semibold tracking-[-0.005em] text-fg first:pt-0 ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>;
148+ case "table":
149+ return <TableView block={block} context={context} />;
147150 case "hr":
148151 return <hr className="my-2 border-line" />;
149152 case "code":
167170 }
168171 }
169172
173+/**
174+ * A table, as the theme draws tables in rendered Markdown: a quiet header
175+ * row, lines between rows, and its columns aligned as written. A wide one
176+ * scrolls sideways inside the message, never the page, and takes focus so
177+ * it can be scrolled from the keyboard.
178+ */
179+function TableView({ block, context }: { block: Extract<Block, { t: "table" }>; context: TextContext }) {
180+ const align = (index: number) => block.align[index] ?? undefined;
181+ return (
182+ <Table frameProps={{ role: "region", "aria-label": "Table", tabIndex: 0, className: "my-1 rounded-lg border border-line" }} className="min-w-max text-[0.8125rem] leading-snug sm:min-w-full">
183+ <TableHeader>
184+ <TableRow>
185+ {block.head.map((cell, index) => (
186+ <TableHead key={index} scope="col" style={{ textAlign: align(index) }}>
187+ {spans(cell, context)}
188+ </TableHead>
189+ ))}
190+ </TableRow>
191+ </TableHeader>
192+ <TableBody>
193+ {block.rows.map((row, at) => (
194+ <TableRow key={at}>
195+ {row.map((cell, index) => (
196+ <TableCell key={index} style={{ textAlign: align(index) }} className={align(index) === "right" ? "whitespace-nowrap tabular-nums" : "max-w-lg"}>
197+ {spans(cell, context)}
198+ </TableCell>
199+ ))}
200+ </TableRow>
201+ ))}
202+ </TableBody>
203+ </Table>
204+ );
205+}
206+
170207 type Token = { content: string; color?: string };
171208
172209 /**
+2−2
121121 aria-label={`${displayName(workspace)}: switch workspace`}
122122 className={cn(
123123 "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-raised focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised",
124− compact ? "h-8 shrink-0 gap-1.5 px-1 max-sm:max-w-40" : "h-9 grow gap-2 px-1.5",
124+ compact ? "h-8 shrink-0 gap-1.5 px-1 max-md:h-11 max-sm:gap-1" : "h-9 grow gap-2 px-1.5",
125125 )}
126126 >
127127 <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square />
128− <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm" : "text-[0.875rem]")}>{displayName(workspace)}</span>
128+ <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm max-sm:hidden" : "text-[0.875rem]")}>{displayName(workspace)}</span>
129129 <ChevronDown size={14} className="shrink-0 text-faint" />
130130 </DropdownMenuTrigger>
131131 <DropdownMenuContent align="start" className="w-72 p-1.5">
+2−1
6363 },
6464 ];
6565 return (
66− <div className="sticky top-(--topbar-h) z-20 -mx-4 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-4 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
66+ // Right under the top bar, edge to edge, and one block with it (data-page-head in components/shell.tsx).
67+ <div data-page-head="sticky" className="sticky top-(--topbar-h) z-20 -mx-4 flex h-14 items-center gap-2 border-b border-line bg-bg/90 px-4 backdrop-blur sm:-mx-6 sm:px-5 lg:-mx-8">
6768 <div className="min-w-0 grow">
6869 <Crumbs items={crumbs} />
6970 </div>
+4−4
77 import { WaitingCards } from "./notifications/card-actions";
88 import { SubmitButton } from "./ui";
99 import { Badge, type BadgeTone } from "./ui/badge";
10+import { Button } from "./ui/button";
1011 import { Hint } from "./ui/hint";
1112 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
1213 import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
272273
273274 return (
274275 <Sheet open={open} onOpenChange={setOpen}>
275− <SheetTrigger
276− aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"}
277− className="relative flex size-9 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg"
278− >
276+ <SheetTrigger asChild>
277+ <Button variant="ghost" size="icon-sm" aria-label={unread ? `Notifications, ${unread} unread` : "Notifications"} className="relative">
279278 <Bell size={17} />
280279 {shown && (
281280 // Amber while an agent waits on them, red for a failure, else the accent.
288287 {shown}
289288 </span>
290289 )}
290+ </Button>
291291 </SheetTrigger>
292292 <SheetContent
293293 // Focus starts on the tab being looked at, not on Mark all read.
+13−0
4646 }
4747
4848 /**
49+ * The 1 set beside type in a control, such as Ask g1t: whole cells, so at
50+ * 14px tall each one lands on 2px (4 on a sharp screen) and every square
51+ * stays sharp, lavender on its own with nothing around it.
52+ */
53+export function MarkGlyph({ className = "h-3.5" }: { className?: string }) {
54+ return (
55+ <svg viewBox="0 0 50 70" className={`w-auto shrink-0 ${className}`} shapeRendering="crispEdges" aria-hidden="true">
56+ <Pixels of={ICON} />
57+ </svg>
58+ );
59+}
60+
61+/**
4962 * The wordmark as one picture, sized by its font size: a cell is 0.125em,
5063 * so the letters stand 0.85em tall on the baseline, about the height of
5164 * the type around them.
+3−8
77 import { dueInWords, isOverdue, percentDone } from "../lib/labels";
88 import { cn } from "../lib/cn";
99 import { Button, ErrorText, SubmitButton, Textarea, usePending } from "./ui";
10+import { Input } from "./ui/input";
1011 import {
1112 AlertDialog,
1213 AlertDialogAction,
6566 <div className="grid gap-3 sm:grid-cols-[1fr_12rem]">
6667 <label className="block">
6768 <span className="mb-1.5 block text-xs font-medium text-muted">Title</span>
68− <input
69+ <Input
6970 name="title"
7071 defaultValue={milestone?.title ?? ""}
7172 required
7475 autoComplete="off"
7576 data-1p-ignore
7677 placeholder="Launch"
77− className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
7878 />
7979 </label>
8080 <label className="block">
8181 <span className="mb-1.5 block text-xs font-medium text-muted">Due date (optional)</span>
82− <input
83− type="date"
84− name="dueOn"
85− defaultValue={milestone?.dueOn ?? ""}
86− className="h-9 w-full rounded-md border border-line bg-bg px-3 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
87− />
82+ <Input type="date" name="dueOn" defaultValue={milestone?.dueOn ?? ""} />
8883 </label>
8984 </div>
9085 <label className="block">
+8−10
77 import { agentRunHref, workflowRunHref } from "../lib/runners";
88 import type { RunnersAction, RunnersData } from "../lib/runners.server";
99 import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
10+import { Checkbox } from "./ui/checkbox";
11+import { RadioGroup, RadioOption } from "./ui/radio-group";
1012 import { SelectField } from "./ui/select";
1113 import { Switch } from "./ui/switch";
1214 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
316318 </Field>
317319 <fieldset className="space-y-2 text-sm">
318320 <legend className="mb-1 text-xs text-muted">Repositories that may use its runners</legend>
319− <label className="flex items-center gap-2">
320− <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} />
321− Every repository in the workspace
322− </label>
323− <label className="flex items-center gap-2">
324− <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} />
325− Only these
326− </label>
321+ <RadioGroup name="reach" value={reach} onValueChange={setReach}>
322+ <RadioOption value="all" label="Every repository in the workspace" />
323+ <RadioOption value="some" label="Only these" />
324+ </RadioGroup>
327325 {reach === "some" && (
328326 <div className="grid gap-1 pl-6 sm:grid-cols-2">
329327 {repositories.map((name) => (
330− <label key={name} className="flex items-center gap-2 font-mono text-xs">
331− <input type="checkbox" name="repository" value={name} defaultChecked={group?.repositories.includes(name)} />
328+ <label key={name} className="flex min-h-8 cursor-pointer items-center gap-2 font-mono text-xs">
329+ <Checkbox name="repository" value={name} defaultChecked={group?.repositories.includes(name)} />
332330 {name}
333331 </label>
334332 ))}
+146−91
11 import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react";
2−import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2+import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
55 import { type Abilities, type ChatSidebarEntry, type InboxCounts, type WorkspaceAgent, type Membership, type Spike, type User, hasCodeAccess, mayCreateTeams, shownUsername } from "@g1t/contracts";
1212 import { Hint } from "./ui/hint";
1313 import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
1414 import { StatusDot, useSiteStatus } from "./footer";
15−import { Logo } from "./logo";
15+import { Logo, MarkGlyph } from "./logo";
1616 import { Avatar, SoonPill } from "./ui";
1717 import { Skeleton } from "./ui/skeleton";
18+import { Button } from "./ui/button";
19+import { Separator } from "./ui/separator";
20+import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb";
1821 import {
1922 DropdownMenu,
2023 DropdownMenuContent,
3033 import { ChatSidebar } from "./chat/sidebar";
3134 import { FoliosSidebar } from "./folios/sidebar";
3235 import { Dock, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./dock";
33−import { G1tMark } from "./orchestrator";
3436 import { BottomBar, isConversation, useVisualViewport } from "./mobile";
3537 import { useChatSidebar } from "./chat/actions";
3638 import { unreadTotals } from "../lib/chat";
14511453 // visible and only something inside it is missing.
14521454 const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname);
14531455 if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) {
1454− return <span className="text-sm font-medium">Not found</span>;
1456+ return <Here>Not found</Here>;
14551457 }
14561458 const parts = pathname.split("/").filter(Boolean);
14571459 const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "notifications"];
1458− if (parts.length === 0) return <span className="text-sm font-medium">Home</span>;
1460+ if (parts.length === 0) return <Here>Home</Here>;
14591461 // Your settings: Settings / Emails.
14601462 if (parts[0] === "settings") {
14611463 const page = accountSettingsPage(pathname);
14751477 invite: "Invite",
14761478 notifications: "Notifications",
14771479 };
1478− return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
1480+ return <Here>{words[parts[0]!]}</Here>;
14791481 }
14801482 // A person's profile, by their handle.
14811483 if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />;
15181520 const page = useRouteLoaderData("routes/user") as { profile?: { username: string; displayUsername?: string | null; avatar: string | null } } | undefined;
15191521 const profile = page?.profile?.username.toLowerCase() === username.toLowerCase() ? page.profile : null;
15201522 return (
1521− <Link to={`/u/${profile?.username ?? username}`} aria-current="page" className="flex min-w-0 items-center gap-2 rounded px-1 py-0.5 transition-colors hover:bg-raised">
1522− <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} />
1523− <span className="truncate font-mono text-[0.8125rem] font-medium">@{profile ? shownUsername(profile) : username}</span>
1524− </Link>
1523+ <Breadcrumb className="min-w-0">
1524+ <BreadcrumbList>
1525+ <BreadcrumbItem>
1526+ <BreadcrumbPage asChild className="flex items-center gap-2 hover:bg-raised">
1527+ <Link to={`/u/${profile?.username ?? username}`}>
1528+ <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} />
1529+ <span className="truncate font-mono text-[0.8125rem]">@{profile ? shownUsername(profile) : username}</span>
1530+ </Link>
1531+ </BreadcrumbPage>
1532+ </BreadcrumbItem>
1533+ </BreadcrumbList>
1534+ </Breadcrumb>
1535+ );
1536+}
1537+
1538+/** A page with no trail to it, such as Today or Search: its name alone. */
1539+function Here({ children }: { children: ReactNode }) {
1540+ return (
1541+ <Breadcrumb className="min-w-0">
1542+ <BreadcrumbList>
1543+ <BreadcrumbItem>
1544+ <BreadcrumbPage>{children}</BreadcrumbPage>
1545+ </BreadcrumbItem>
1546+ </BreadcrumbList>
1547+ </Breadcrumb>
15251548 );
15261549 }
15271550
15361559 // Up on a phone, unless up is the workspace itself: its pages are the tabs.
15371560 const up = trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to) ? trail[trail.length - 2]! : null;
15381561 return (
1539− <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1562+ <Breadcrumb className="flex min-w-0 items-center">
15401563 {up && (
1541− <Link
1542− to={up.to}
1543− prefetch="intent"
1544− aria-label={`Back to ${up.label}`}
1545− className="-ml-1 flex size-8 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-raised hover:text-fg sm:hidden"
1546− >
1547− <ChevronLeft size={16} />
1548− </Link>
1564+ <Button asChild variant="ghost" size="icon-bar" className="-ml-1 sm:hidden">
1565+ <Link to={up.to} prefetch="intent" aria-label={`Back to ${up.label}`}>
1566+ <ChevronLeft size={16} />
1567+ </Link>
1568+ </Button>
15491569 )}
1550− {trail.map((crumb, index) => {
1551− const last = index === trail.length - 1;
1552− return (
1553− <span key={index} className={`min-w-0 items-center gap-1.5 ${last ? "flex" : "hidden sm:flex"}`}>
1554− {index > 0 && <span className="hidden text-line-strong sm:inline">/</span>}
1555− <Link
1556− to={crumb.to}
1557− prefetch="intent"
1558− aria-current={last ? "page" : undefined}
1559− className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1560− last ? "font-medium text-fg" : "text-muted hover:text-fg"
1561− } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
1562− >
1563− {last && crumb.short ? (
1564− <>
1565− <span className="sm:hidden">{crumb.short}</span>
1566− <span className="hidden sm:inline">{crumb.label}</span>
1567− </>
1568− ) : (
1569− crumb.label
1570− )}
1571− </Link>
1572− </span>
1573− );
1574− })}
1575− </nav>
1570+ <BreadcrumbList>
1571+ {trail.map((crumb, index) => {
1572+ const last = index === trail.length - 1;
1573+ const label =
1574+ last && crumb.short ? (
1575+ <>
1576+ <span className="sm:hidden">{crumb.short}</span>
1577+ <span className="hidden sm:inline">{crumb.label}</span>
1578+ </>
1579+ ) : (
1580+ crumb.label
1581+ );
1582+ const mono = crumb.mono ? "font-mono text-[0.8125rem]" : "";
1583+ return (
1584+ <Fragment key={index}>
1585+ {index > 0 && <BreadcrumbSeparator className="max-sm:hidden" />}
1586+ <BreadcrumbItem className={last ? "" : "max-sm:hidden"}>
1587+ {last ? (
1588+ // The page you are on is still a link: to its own top, as the trail always was.
1589+ <BreadcrumbPage asChild className={`hover:bg-raised ${mono}`}>
1590+ <Link to={crumb.to} prefetch="intent">
1591+ {label}
1592+ </Link>
1593+ </BreadcrumbPage>
1594+ ) : (
1595+ <BreadcrumbLink asChild className={mono}>
1596+ <Link to={crumb.to} prefetch="intent">
1597+ {label}
1598+ </Link>
1599+ </BreadcrumbLink>
1600+ )}
1601+ </BreadcrumbItem>
1602+ </Fragment>
1603+ );
1604+ })}
1605+ </BreadcrumbList>
1606+ </Breadcrumb>
15761607 );
15771608 }
15781609
17251756 );
17261757 }
17271758
1728−/** The top bar's way to g1t: the direct message with the workspace's orchestrator, opened or made. */
1759+/**
1760+ * The top bar's way to g1t: the direct message with the workspace's
1761+ * orchestrator, opened or made. The one tinted control in the bar: the
1762+ * lavender pixel 1 bare beside its words, on a wash of the accent.
1763+ */
17291764 function AskG1tButton({ slug }: { slug: string }) {
17301765 return (
17311766 <Hint label="Ask g1t">
1732− <Link
1733− to={`/${slug}/-/chat?agent=g1t`}
1734− aria-label="Ask g1t"
1735− className="flex h-8 items-center gap-1.5 rounded-md border border-line px-1.5 text-sm text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg sm:px-2.5"
1736− >
1737− <G1tMark size={18} />
1738− <span className="hidden lg:inline">Ask g1t</span>
1739− </Link>
1767+ <Button asChild variant="soft" size="bar" className="gap-2 lg:px-3">
1768+ <Link to={`/${slug}/-/chat?agent=g1t`} aria-label="Ask g1t">
1769+ <MarkGlyph className="h-3.5" />
1770+ <span className="max-lg:hidden">Ask g1t</span>
1771+ </Link>
1772+ </Button>
17401773 </Hint>
17411774 );
17421775 }
17881821 }, [toggle]);
17891822 }
17901823
1824+/**
1825+ * Search or jump to: the command palette. On a computer it looks like the
1826+ * field it opens, with its shortcut; on a phone, a magnifier.
1827+ */
1828+function SearchButton({ onClick }: { onClick: () => void }) {
1829+ return (
1830+ <>
1831+ <Button variant="ghost" size="icon-bar" aria-label="Search or jump to" onClick={onClick} className="md:hidden">
1832+ <Search size={18} />
1833+ </Button>
1834+ <Button
1835+ variant="outline"
1836+ size="sm"
1837+ aria-label="Search or jump to"
1838+ onClick={onClick}
1839+ className="mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden"
1840+ >
1841+ <Search size={15} />
1842+ <span className="grow truncate text-left">Search or jump to</span>
1843+ <PaletteKey className="rounded border border-line bg-bg px-1.5 font-mono text-[0.625rem] leading-4 text-muted" />
1844+ </Button>
1845+ </>
1846+ );
1847+}
1848+
17911849 /** The button that shows or hides the sidebar. */
17921850 function SidebarToggle({ onClick, open, className = "" }: { onClick: () => void; open: boolean; className?: string }) {
17931851 const key = sidebarKeyLabel(typeof navigator === "undefined" ? null : navigator.platform);
17941852 return (
17951853 <Hint label={`Toggle sidebar (${key})`}>
1796− <button
1797− type="button"
1798− aria-label={open ? "Hide the sidebar" : "Show the sidebar"}
1799− aria-expanded={open}
1800− onClick={onClick}
1801− className={`flex size-8 shrink-0 items-center justify-center rounded-md text-muted outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent ${className}`}
1802− >
1854+ <Button variant="ghost" size="icon-sm" aria-label={open ? "Hide the sidebar" : "Show the sidebar"} aria-expanded={open} onClick={onClick} className={className}>
18031855 <PanelLeft size={17} />
1804− </button>
1856+ </Button>
18051857 </Hint>
18061858 );
18071859 }
18441896 usePaletteShortcut(() => setPalette((open) => !open));
18451897 const leaving = useLeaving();
18461898 useVisualViewport();
1899+ // Whether the page has scrolled under the top bar, which then draws its line (see data-page-head).
1900+ const [scrolled, setScrolled] = useState(false);
1901+ useEffect(() => {
1902+ const onScroll = () => setScrolled(window.scrollY > 2);
1903+ onScroll();
1904+ window.addEventListener("scroll", onScroll, { passive: true });
1905+ return () => window.removeEventListener("scroll", onScroll);
1906+ }, []);
18471907
18481908 const going = useNavigation().location?.pathname;
18491909 const ws = shell.workspace;
19662026 </SheetContent>
19672027
19682028 <div className={`min-h-dvh px-1.5 pt-(--frame-top) pb-(--tabbar-h) md:pr-2 ${pad}`}>
1969− <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col rounded-[14px] bg-bg">
2029+ {/* A page whose own header sits right under this bar (data-page-head: a conversation's, a project's) reads as one
2030+ block with it: no line between them until the page scrolls under the bar. A sticky one (data-page-head="sticky",
2031+ an open artifact's) stays under the bar, so the line is always its. */}
2032+ <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--tabbar-h))] min-w-0 flex-col rounded-[14px] bg-bg [&:has([data-page-head])>header:not([data-scrolled])]:border-b-transparent [&:has([data-page-head=sticky])>header]:border-b-transparent">
19702033 <header
1971− className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1.5 rounded-t-[14px] border-b border-line bg-bg pr-2 pl-2 sm:gap-2 sm:pr-3 ${conversation ? "max-md:hidden" : ""}`}
2034+ data-scrolled={scrolled || undefined}
2035+ className={`sticky top-(--frame-top) z-30 flex h-14 items-center gap-1 rounded-t-[14px] border-b border-line bg-bg pr-2 pl-2 transition-[border-color] duration-150 sm:pr-3 ${inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`}
19722036 >
19732037 {panel && (
1974− <SidebarToggle open={drawer} onClick={toggleSidebar} className={inline ? "lg:hidden" : ""} />
2038+ <SidebarToggle open={drawer} onClick={toggleSidebar} className={`max-md:size-11 ${inline ? "lg:hidden" : ""}`} />
19752039 )}
19762040 {ws && (
19772041 <span className={`flex min-w-0 shrink items-center gap-1.5 ${inline ? "lg:hidden" : ""}`}>
19822046 </span>
19832047 )}
19842048 <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} workspace={ws?.slug ?? null} />
1985− <div className="ml-auto flex shrink-0 items-center gap-1.5">
1986− <button
1987− type="button"
1988− aria-label="Search or jump to"
1989− onClick={() => setPalette(true)}
1990− className="flex h-8 items-center gap-2 rounded-md text-faint transition-colors hover:text-muted max-md:w-8 max-md:justify-center max-md:hover:bg-raised md:w-[min(17rem,26vw)] md:border md:border-line md:bg-surface md:px-2.5 md:hover:border-line-strong"
1991− >
1992− <Search size={15} className="shrink-0" />
1993− <span className="hidden grow truncate text-left text-[0.8125rem] md:inline">Search or jump to</span>
1994− <PaletteKey className="hidden rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line md:inline" />
1995− </button>
2049+ {/* Search, then what's yours (spend, Ask g1t), then the bell and Create new: one height, one rhythm. */}
2050+ <div className="ml-auto flex shrink-0 items-center gap-1 md:gap-1.5">
2051+ <SearchButton onClick={() => setPalette(true)} />
19962052 {/* Your spend this month, or the workspace's for owners and billing managers; on a phone it is on Spend. */}
19972053 {ws && (
1998− <span className="max-md:hidden">
2054+ <span className="flex max-md:hidden">
19992055 <SpendPill slug={ws.slug} mayWorkspace={ws.role === "owner" || !!ws.org_roles?.includes("billing_manager")} />
20002056 </span>
20012057 )}
20022058 {ws && <AskG1tButton slug={ws.slug} />}
2059+ <Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-5 max-md:hidden" />
20032060 {/* On a phone, Notifications is in the bottom bar. */}
2004− <span className="max-md:hidden">
2061+ <span className="flex max-md:hidden">
20052062 <NotificationsBell counts={shell.inbox ? { ...shell.inbox, unread: notificationsUnread } : null} />
20062063 </span>
20072064 <CreateMenu shell={shell} />
20152072 </main>
20162073 </div>
20172074 </div>
2018− {/* The panel's edge: a rounded frame over the page, with the background beyond it, so what scrolls stays inside. */}
2019− {!conversation && (
2020− <div
2021− aria-hidden="true"
2022− className={`pointer-events-none fixed top-(--frame-top) right-1.5 bottom-(--tabbar-h) left-1.5 z-[35] rounded-[14px] shadow-[0_0_0_1px_var(--color-line),0_0_0_100vmax_var(--color-shell)] md:right-2 ${left}`}
2023− />
2024− )}
2075+ {/* The panel's edge: a rounded frame over the page, with the background beyond it, so what scrolls stays inside.
2076+ Every page has it, a conversation too; only a phone's conversation, full screen over everything, goes without. */}
2077+ <div
2078+ aria-hidden="true"
2079+ className={`pointer-events-none fixed top-(--frame-top) right-1.5 bottom-(--tabbar-h) left-1.5 z-[35] rounded-[14px] shadow-[0_0_0_1px_var(--color-line),0_0_0_100vmax_var(--color-shell)] md:right-2 ${left} ${conversation ? "max-md:hidden" : ""}`}
2080+ />
20252081 {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />}
20262082 <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} />
20272083 </Sheet>
20332089 return (
20342090 <DropdownMenu>
20352091 <Hint label="Create new">
2036− <DropdownMenuTrigger
2037− aria-label="Create new"
2038− className="flex h-8 items-center gap-1 rounded-md border border-line px-1.5 text-fg/90 outline-none transition-colors hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised sm:px-2"
2039− >
2040− <Plus size={16} />
2041− <ChevronDown size={13} className="text-muted max-sm:hidden" />
2092+ <DropdownMenuTrigger asChild>
2093+ <Button variant="ghost" size="bar" aria-label="Create new" className="gap-0.5 px-1.5 text-fg/90">
2094+ <Plus size={17} className="max-md:size-5" />
2095+ <ChevronDown size={12} className="text-faint max-md:hidden" />
2096+ </Button>
20422097 </DropdownMenuTrigger>
20432098 </Hint>
20442099 <DropdownMenuContent align="end" className="min-w-52">
+9−4
1515 import { Meter, PrivateTitle, SliceList, stepsLine } from "./agents/parts";
1616 import { monthName, shortDay } from "./agents/format";
1717 import { Badge } from "./ui/badge";
18+import { Button } from "./ui/button";
1819 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
1920 import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
2021 import { Hint } from "./ui/hint";
589590 const label = spent == null ? (data ? "Spend" : "") : money(spent);
590591 return (
591592 <Popover open={open} onOpenChange={setOpen}>
592− <PopoverTrigger
593− aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`}
594− className="flex h-8 items-center gap-2 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/90 tabular-nums transition-colors outline-none hover:border-line-strong hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised"
595− >
593+ <PopoverTrigger asChild>
594+ <Button
595+ variant="ghost"
596+ size="sm"
597+ aria-label={spent == null ? "Spend this month" : `${ws ? "The workspace's" : "Your"} spend this month: ${money(spent)}${budget != null ? ` of ${money(budget)}` : ""}`}
598+ className="gap-2 text-fg-soft tabular-nums"
599+ >
596600 {ws ? <Building2 size={14} className="shrink-0 text-faint" /> : <Coins size={14} className="shrink-0 text-faint" />}
597601 {data ? <span>{label}</span> : <Skeleton className="h-3 w-10" />}
598602 {share != null && (
600604 <Meter spent={spent!} cap={budget} label="Spent of the budget" size="sm" />
601605 </span>
602606 )}
607+ </Button>
603608 </PopoverTrigger>
604609 <PopoverContent align="end" className="w-80 p-0">
605610 <SpendPopover slug={slug} data={data ?? null} scope={scope} mayWorkspace={mayWorkspace} onScope={choose} onClose={() => setOpen(false)} />
+18−10
1−import { ShieldAlert, TriangleAlert } from "lucide-react";
1+import { Check, ShieldAlert, TriangleAlert } from "lucide-react";
22 import { useState } from "react";
33
44 import {
4747 return (
4848 <Hint label={by ? `Included in ${by}` : undefined}>
4949 <label className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")}>
50− <input
51− type="checkbox"
52− name="scope"
53− value={scope}
54− checked={checked}
55− disabled={by !== null}
56− onChange={(event) => onToggle(scope, event.target.checked)}
57− className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")}
58− />
50+ {/* The real checkbox, drawn as the ui Checkbox is, so it still posts without script. */}
51+ <span className="relative mt-0.5 flex size-4 shrink-0">
52+ <input
53+ type="checkbox"
54+ name="scope"
55+ value={scope}
56+ checked={checked}
57+ disabled={by !== null}
58+ onChange={(event) => onToggle(scope, event.target.checked)}
59+ className={cn(
60+ "peer size-4 cursor-[inherit] appearance-none rounded-[5px] border border-line-strong bg-bg transition-colors hover:border-faint disabled:opacity-50",
61+ "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
62+ isDangerous(scope) ? "checked:border-danger checked:bg-danger" : "checked:border-accent checked:bg-accent",
63+ )}
64+ />
65+ <Check size={12} strokeWidth={3} aria-hidden className="pointer-events-none absolute inset-0 m-auto hidden text-bg peer-checked:block peer-disabled:opacity-50" />
66+ </span>
5967 <span className="min-w-0">
6068 <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}>
6169 {scope}
+63−0
1+import { MoreHorizontal } from "lucide-react";
2+import { Slot } from "radix-ui";
3+import type { ComponentProps } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's breadcrumb, styled with g1t's tokens: where you are as a
8+// trail of links, the page you are on last and not a link of its own
9+// (BreadcrumbPage), with a quiet `/` between. `asChild` on a link puts its
10+// look on a router <Link>.
11+
12+export function Breadcrumb(props: ComponentProps<"nav">) {
13+ return <nav aria-label="Breadcrumb" data-slot="breadcrumb" {...props} />;
14+}
15+
16+export function BreadcrumbList({ className, ...props }: ComponentProps<"ol">) {
17+ return <ol data-slot="breadcrumb-list" className={cn("flex min-w-0 items-center gap-1 text-sm text-muted", className)} {...props} />;
18+}
19+
20+export function BreadcrumbItem({ className, ...props }: ComponentProps<"li">) {
21+ return <li data-slot="breadcrumb-item" className={cn("inline-flex min-w-0 items-center gap-1", className)} {...props} />;
22+}
23+
24+export function BreadcrumbLink({ asChild, className, ...props }: ComponentProps<"a"> & { asChild?: boolean }) {
25+ const Comp = asChild ? Slot.Root : "a";
26+ return (
27+ <Comp
28+ data-slot="breadcrumb-link"
29+ className={cn("truncate rounded-md px-1.5 py-1 outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent", className)}
30+ {...props}
31+ />
32+ );
33+}
34+
35+/** The page you are on: the trail's end, in the foreground. */
36+export function BreadcrumbPage({ asChild, className, ...props }: ComponentProps<"span"> & { asChild?: boolean }) {
37+ const Comp = asChild ? Slot.Root : "span";
38+ return (
39+ <Comp
40+ data-slot="breadcrumb-page"
41+ aria-current="page"
42+ className={cn("truncate rounded-md px-1.5 py-1 font-medium text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}
43+ {...props}
44+ />
45+ );
46+}
47+
48+export function BreadcrumbSeparator({ children, className, ...props }: ComponentProps<"li">) {
49+ return (
50+ <li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" className={cn("text-line-strong select-none", className)} {...props}>
51+ {children ?? "/"}
52+ </li>
53+ );
54+}
55+
56+export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span">) {
57+ return (
58+ <span data-slot="breadcrumb-ellipsis" role="presentation" aria-hidden="true" className={cn("flex size-8 items-center justify-center", className)} {...props}>
59+ <MoreHorizontal size={16} />
60+ <span className="sr-only">More</span>
61+ </span>
62+ );
63+}
+50−0
1+import { type VariantProps, cva } from "class-variance-authority";
2+import { Slot } from "radix-ui";
3+import type { ComponentProps } from "react";
4+
5+import { cn } from "../../lib/cn";
6+
7+// shadcn/ui's button, styled with g1t's tokens. `asChild` puts its look on
8+// a <Link> or a menu's trigger. The older `Button` in ./index stays until
9+// its callers move here.
10+//
11+// Sizes: `default` 36px, `sm` 32px, `lg` 40px; the icon sizes are squares
12+// of the same. `bar` is the top bar's: 32px on a computer, 44px to a finger
13+// on a phone, and `icon-bar` its square.
14+
15+export const buttonVariants = cva(
16+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap outline-none transition-[color,background-color,border-color,box-shadow] focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
17+ {
18+ variants: {
19+ variant: {
20+ default: "bg-fg text-bg hover:bg-fg-hover",
21+ accent: "bg-accent text-bg hover:bg-accent-hover",
22+ outline: "border border-line bg-bg text-fg/90 hover:border-line-strong hover:bg-surface hover:text-fg",
23+ secondary: "bg-raised text-fg hover:bg-raised/80",
24+ ghost: "text-muted hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg aria-pressed:bg-raised aria-pressed:text-fg",
25+ // The accent as a wash: the one tinted control in a row, such as Ask g1t.
26+ soft: "bg-accent/10 text-fg ring-1 ring-accent/20 ring-inset hover:bg-accent/15 hover:ring-accent/35",
27+ destructive: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10",
28+ link: "text-accent underline-offset-4 hover:underline",
29+ },
30+ size: {
31+ default: "h-9 px-3.5",
32+ sm: "h-8 gap-1.5 px-2.5 text-[0.8125rem]",
33+ lg: "h-10 px-5",
34+ bar: "h-8 gap-1.5 px-2 text-[0.8125rem] max-md:h-11 max-md:min-w-11",
35+ icon: "size-9",
36+ "icon-sm": "size-8",
37+ "icon-lg": "size-10",
38+ "icon-bar": "size-8 max-md:size-11",
39+ },
40+ },
41+ defaultVariants: { variant: "default", size: "default" },
42+ },
43+);
44+
45+export type ButtonProps = ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean };
46+
47+export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
48+ const Comp = asChild ? Slot.Root : "button";
49+ return <Comp data-slot="button" className={cn(buttonVariants({ variant, size }), className)} {...props} />;
50+}
+166−0
1+import { FileText, Upload, X } from "lucide-react";
2+import { type ChangeEvent, type DragEvent, type ReactNode, useEffect, useId, useRef, useState } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// A file field as a dropzone. The real <input type="file"> is inside it,
7+// visually hidden, so its name, accept, multiple and required work as they
8+// always do and the form posts the files exactly as a bare input would. A
9+// click or Enter on the zone opens the picker; files dropped on it are put
10+// on the input and a change event is sent, as if they had been picked.
11+
12+type Chosen = { name: string; size: number };
13+
14+/** Bytes as a person reads them: 812 B, 14.2 KB, 1.3 MB. */
15+function fileSize(bytes: number): string {
16+ if (bytes < 1024) return `${bytes} B`;
17+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
18+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
19+}
20+
21+export function FileDrop({
22+ id,
23+ name,
24+ accept,
25+ multiple = false,
26+ required,
27+ hint,
28+ label,
29+ compact = false,
30+ className,
31+ onChange,
32+ "aria-describedby": describedBy,
33+}: {
34+ id?: string;
35+ name?: string;
36+ accept?: string;
37+ multiple?: boolean;
38+ required?: boolean;
39+ /** A line under the prompt, such as the types and the size allowed. */
40+ hint?: ReactNode;
41+ /** The field's name for a screen reader, when nothing else labels it. */
42+ label?: string;
43+ /** One row, for a tight spot beside other controls. */
44+ compact?: boolean;
45+ className?: string;
46+ onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
47+ "aria-describedby"?: string;
48+}) {
49+ const generated = useId();
50+ const inputId = id ?? generated;
51+ const hintId = `${inputId}-hint`;
52+ const input = useRef<HTMLInputElement>(null);
53+ const [files, setFiles] = useState<Chosen[]>([]);
54+ const [dragging, setDragging] = useState(false);
55+
56+ // A form reset empties the input; the list follows it.
57+ useEffect(() => {
58+ const form = input.current?.form;
59+ if (!form) return;
60+ const onReset = () => setFiles([]);
61+ form.addEventListener("reset", onReset);
62+ return () => form.removeEventListener("reset", onReset);
63+ }, []);
64+
65+ const changed = (event: ChangeEvent<HTMLInputElement>) => {
66+ setFiles(Array.from(event.currentTarget.files ?? [], (file) => ({ name: file.name, size: file.size })));
67+ onChange?.(event);
68+ };
69+
70+ const drop = (event: DragEvent<HTMLLabelElement>) => {
71+ event.preventDefault();
72+ setDragging(false);
73+ const field = input.current;
74+ const dropped = event.dataTransfer.files;
75+ if (!field || dropped.length === 0) return;
76+ if (multiple || dropped.length === 1) {
77+ field.files = dropped;
78+ } else {
79+ const one = new DataTransfer();
80+ one.items.add(dropped[0]);
81+ field.files = one.files;
82+ }
83+ field.dispatchEvent(new Event("change", { bubbles: true }));
84+ };
85+
86+ const clear = () => {
87+ const field = input.current;
88+ if (!field) return;
89+ field.value = "";
90+ field.dispatchEvent(new Event("change", { bubbles: true }));
91+ field.focus();
92+ };
93+
94+ const describedByIds = [describedBy, hint ? hintId : null].filter(Boolean).join(" ") || undefined;
95+ const plural = multiple;
96+
97+ return (
98+ <div className={cn("min-w-0", className)}>
99+ <label
100+ htmlFor={inputId}
101+ onDragEnter={(event) => {
102+ event.preventDefault();
103+ setDragging(true);
104+ }}
105+ onDragOver={(event) => {
106+ event.preventDefault();
107+ event.dataTransfer.dropEffect = "copy";
108+ }}
109+ onDragLeave={(event) => {
110+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
111+ }}
112+ onDrop={drop}
113+ className={cn(
114+ "relative flex cursor-pointer rounded-lg border border-dashed border-line-strong bg-bg text-sm text-muted transition-colors",
115+ "hover:border-accent/50 hover:bg-surface has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent has-[:focus-visible]:ring-offset-1 has-[:focus-visible]:ring-offset-bg",
116+ compact ? "min-h-11 items-center gap-2 px-3 py-1.5 sm:min-h-9" : "flex-col items-center justify-center gap-1.5 px-4 py-6 text-center",
117+ dragging && "border-accent bg-accent/5 hover:border-accent hover:bg-accent/5",
118+ )}
119+ >
120+ <input
121+ ref={input}
122+ id={inputId}
123+ type="file"
124+ name={name}
125+ accept={accept}
126+ multiple={multiple}
127+ required={required}
128+ aria-describedby={describedByIds}
129+ onChange={changed}
130+ className="sr-only"
131+ />
132+ {label && <span className="sr-only">{label}</span>}
133+ <Upload size={compact ? 14 : 18} aria-hidden className={cn("shrink-0", dragging ? "text-accent" : "text-faint")} />
134+ <span className="min-w-0">
135+ <span className="font-medium text-accent">{plural ? "Choose files" : "Choose a file"}</span> or drop {plural ? "them" : "it"} here
136+ </span>
137+ {hint && (
138+ <span id={hintId} className={cn("text-xs text-faint", compact && "sr-only")}>
139+ {hint}
140+ </span>
141+ )}
142+ </label>
143+ {files.length > 0 && (
144+ <div className="mt-2 flex items-start gap-2 rounded-lg border border-line bg-surface py-1 pr-1 pl-3">
145+ <ul aria-label="Chosen files" className="min-w-0 grow">
146+ {files.map((file, index) => (
147+ <li key={`${file.name}-${index}`} className="flex min-h-8 items-center gap-2 text-sm">
148+ <FileText size={14} aria-hidden className="shrink-0 text-faint" />
149+ <span className="min-w-0 grow truncate">{file.name}</span>
150+ <span className="shrink-0 text-xs text-faint tabular-nums">{fileSize(file.size)}</span>
151+ </li>
152+ ))}
153+ </ul>
154+ <button
155+ type="button"
156+ onClick={clear}
157+ className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent"
158+ >
159+ <X size={13} aria-hidden />
160+ Clear
161+ </button>
162+ </div>
163+ )}
164+ </div>
165+ );
166+}
+20−0
1+import { Separator as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's separator, styled with g1t's tokens: a hairline in `line`,
7+// across or (vertical) up and down. Decorative unless told otherwise, so a
8+// screen reader passes over it.
9+
10+export function Separator({ className, orientation = "horizontal", decorative = true, ...props }: ComponentProps<typeof Primitive.Root>) {
11+ return (
12+ <Primitive.Root
13+ data-slot="separator"
14+ decorative={decorative}
15+ orientation={orientation}
16+ className={cn("shrink-0 bg-line data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", className)}
17+ {...props}
18+ />
19+ );
20+}
+43−0
1+import type { ComponentProps } from "react";
2+
3+import { cn } from "../../lib/cn";
4+
5+// shadcn/ui's table, styled with g1t's tokens. The table sits in a frame
6+// that scrolls sideways on its own, so a wide one never widens the page;
7+// the frame takes focus when it has to scroll, for the keyboard.
8+
9+export function Table({ className, frameProps, ...props }: Omit<ComponentProps<"table">, "frame"> & { frameProps?: ComponentProps<"div"> }) {
10+ return (
11+ <div
12+ data-slot="table-frame"
13+ {...frameProps}
14+ className={cn("relative w-full overflow-x-auto outline-none [scrollbar-width:thin] focus-visible:ring-2 focus-visible:ring-accent", frameProps?.className)}
15+ >
16+ <table data-slot="table" className={cn("w-full caption-bottom border-collapse text-sm", className)} {...props} />
17+ </div>
18+ );
19+}
20+
21+export function TableHeader({ className, ...props }: ComponentProps<"thead">) {
22+ return <thead data-slot="table-header" className={cn("bg-surface [&_tr]:border-b [&_tr]:border-line", className)} {...props} />;
23+}
24+
25+export function TableBody({ className, ...props }: ComponentProps<"tbody">) {
26+ return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
27+}
28+
29+export function TableRow({ className, ...props }: ComponentProps<"tr">) {
30+ return <tr data-slot="table-row" className={cn("border-b border-line transition-colors data-[state=selected]:bg-raised", className)} {...props} />;
31+}
32+
33+export function TableHead({ className, ...props }: ComponentProps<"th">) {
34+ return <th data-slot="table-head" className={cn("h-9 px-3 text-left align-middle font-medium whitespace-nowrap text-muted", className)} {...props} />;
35+}
36+
37+export function TableCell({ className, ...props }: ComponentProps<"td">) {
38+ return <td data-slot="table-cell" className={cn("px-3 py-2 align-top", className)} {...props} />;
39+}
40+
41+export function TableCaption({ className, ...props }: ComponentProps<"caption">) {
42+ return <caption data-slot="table-caption" className={cn("mt-3 text-xs text-faint", className)} {...props} />;
43+}
+5−3
3030 usageCsv,
3131 } from "../lib/usage";
3232 import { cn } from "../lib/cn";
33+import { Checkbox } from "./ui/checkbox";
3334 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
3435 import { Hint } from "./ui/hint";
36+import { Input } from "./ui/input";
3537 import { SelectField } from "./ui/select";
3638 import { Skeleton } from "./ui/skeleton";
3739
6971 {options.map((option) => (
7072 <li key={option.value}>
7173 <label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-surface">
72− <input type="checkbox" name={name} value={option.value} defaultChecked={chosen.includes(option.value)} className="accent-[var(--color-accent)]" />
74+ <Checkbox name={name} value={option.value} defaultChecked={chosen.includes(option.value)} />
7375 <span className="truncate">{option.label}</span>
7476 </label>
7577 </li>
130132 />
131133 {filters.period === "custom" ? (
132134 <span className="flex items-center gap-1 text-sm">
133− <input type="date" name="from" defaultValue={filters.from} aria-label="From" className={SELECT} />
135+ <Input type="date" name="from" defaultValue={filters.from} aria-label="From" className="h-8 w-auto px-2" />
134136 <span className="text-faint">to</span>
135− <input type="date" name="until" defaultValue={filters.until} aria-label="Until" className={SELECT} />
137+ <Input type="date" name="until" defaultValue={filters.until} aria-label="Until" className="h-8 w-auto px-2" />
136138 </span>
137139 ) : (
138140 <span className="px-1 text-sm text-muted tabular-nums">{rangeLabel(filters.from, filters.until)}</span>
+1−0
7171 "ask @ana in #web about g1t#12",
7272 "\\- not a list and 2\\*3 and snake_case",
7373 "**a _b_ c**",
74+ "| Session | Length |\n| --- | --: |\n| **Welcome** | `45` min |",
7475 ];
7576 for (const sample of samples) assert.equal(docToMarkdown(markdownToDoc(sample)), sample, sample);
7677 });
+7−0
8787 break;
8888 case "hr":
8989 break;
90+ case "table": {
91+ // The composer has no tables: each row comes back as the line it was written as.
92+ const row = (cells: Span[][]): Span[] => [{ t: "text", v: "| " }, ...cells.flatMap((cell, at) => [...(at ? [{ t: "text" as const, v: " | " }] : []), ...cell]), { t: "text", v: " |" }];
93+ const rule = block.align.map((align) => (align === "center" ? ":-:" : align === "right" ? "--:" : align === "left" ? ":--" : "---"));
94+ out.push(paragraph(row(block.head)), paragraph([{ t: "text", v: `| ${rule.join(" | ")} |` }]), ...block.rows.map((cells) => paragraph(row(cells))));
95+ break;
96+ }
9097 case "code":
9198 out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) });
9299 break;
+3−3
202202 // the page shows its name, and the views of the page it is on as tabs.
203203 return (
204204 <>
205− <div className="border-b border-line">
206− <div className={`mx-auto max-w-6xl px-4 sm:px-6 ${tabs ? "pt-3" : "py-3"}`}>
205+ <div data-page-head className="border-b border-line">
206+ <div className={`mx-auto max-w-6xl px-4 sm:px-5 ${tabs ? "pt-3" : "py-3"}`}>
207207 <Header
208208 project={project}
209209 isPrivate={repo.isPrivate}
238238 )}
239239 </div>
240240 </div>
241− <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
241+ <div className="mx-auto max-w-6xl px-4 py-6 sm:px-5">
242242 {welcome && (
243243 <div className="mb-6">
244244 <WelcomeBanner title={`You're in ${repo.namespace}/${repo.name}`}>
+2−4
55 import type { Route } from "./+types/release-new";
66 import { encodeTag } from "../../components/releases";
77 import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui";
8+import { CheckboxOption } from "../../components/ui/checkbox";
89 import { requireRepo } from "../../lib/access.server";
910 import { page } from "../../lib/meta";
1011 import { repos } from "../../lib/services.server";
7879 <Field label="Notes" hint="Markdown: what changed, and anything people need to do.">
7980 <Textarea name="body" rows={12} maxLength={MAX_RELEASE_BODY_CHARS} placeholder={"## What changed\n\n- "} />
8081 </Field>
81− <label className="flex items-center gap-2 text-sm">
82− <input type="checkbox" name="prerelease" className="size-4" />
83− Set as a pre-release: not ready for everyone, never the latest
84− </label>
82+ <CheckboxOption name="prerelease" label="Set as a pre-release: not ready for everyone, never the latest" />
8583 {actionData?.error && <ErrorText>{actionData.error}</ErrorText>}
8684 <div className="flex flex-wrap items-center gap-2">
8785 <SubmitButton name="intent" value="publish" pending="Publishing…">
+3−5
1818 import { Badge } from "../../../components/ui/badge";
1919 import { CheckboxOption } from "../../../components/ui/checkbox";
2020 import { Field, FieldDescription, FieldError, FieldLabel } from "../../../components/ui/field";
21+import { FileDrop } from "../../../components/ui/file-drop";
2122 import { Input } from "../../../components/ui/input";
2223 import { SelectField } from "../../../components/ui/select";
2324 import { Textarea } from "../../../components/ui/textarea";
219220 })}
220221 </ul>
221222 )}
222− <div className="mt-3 flex flex-wrap items-center gap-2">
223+ <div className="mt-3 flex flex-wrap items-start gap-2">
223224 <input type="hidden" name="folder" value={folder} />
224225 <SelectField
225226 aria-label="Folder"
231232 onValueChange={setFolder}
232233 className="w-36 font-mono"
233234 />
234− <label className="flex min-w-0 grow cursor-pointer items-center rounded-md border border-dashed border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg">
235− <span className="sr-only">Add files</span>
236− <input type="file" name="files" multiple className="w-full min-w-0 text-xs file:mr-3 file:rounded file:border-0 file:bg-raised file:px-2 file:py-1 file:text-xs file:text-fg" />
237− </label>
235+ <FileDrop name="files" multiple compact label="Add files" className="min-w-0 grow basis-56" />
238236 </div>
239237 <FieldError>{result && !result.ok && result.field === "files" ? result.error : null}</FieldError>
240238 </fieldset>
+3−9
1515 import { skillsPath } from "../../../components/agents/skills";
1616 import { CheckboxOption } from "../../../components/ui/checkbox";
1717 import { Field, FieldDescription, FieldLabel } from "../../../components/ui/field";
18+import { FileDrop } from "../../../components/ui/file-drop";
1819 import { Input } from "../../../components/ui/input";
1920 import { cn } from "../../../lib/cn";
2021 import { page } from "../../../lib/meta";
110111 <input type="hidden" name="intent" value="upload" />
111112 <Field>
112113 <FieldLabel htmlFor={`${id}-file`}>SKILL.md or zip</FieldLabel>
113− <input
114− id={`${id}-file`}
115− type="file"
116− name="file"
117− required
118− accept=".md,.zip,text/markdown,application/zip"
119− className="w-full min-w-0 rounded-md border border-dashed border-line bg-bg px-3 py-3 text-sm text-muted file:mr-3 file:rounded file:border-0 file:bg-raised file:px-2.5 file:py-1 file:text-sm file:text-fg hover:border-line-strong"
120− />
121− <FieldDescription>A zip of the skill&apos;s folder, as zipping the folder makes it. At most 1 MB once unpacked and 200 files.</FieldDescription>
114+ <FileDrop id={`${id}-file`} name="file" required accept=".md,.zip,text/markdown,application/zip" aria-describedby={`${id}-file-about`} />
115+ <FieldDescription id={`${id}-file-about`}>A zip of the skill&apos;s folder, as zipping the folder makes it. At most 1 MB once unpacked and 200 files.</FieldDescription>
122116 </Field>
123117 {replace}
124118 {error("upload")}
+2−2
4040 const folder = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
4141 const encode = (path: string) => path.split("/").map(encodeURIComponent).join("/");
4242 return (
43− <div className="-mt-6">
44− <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">
43+ <div>
44+ <div data-page-head="sticky" className="sticky top-(--topbar-h) z-20 -mx-4 flex h-14 items-center gap-3 border-b border-line bg-bg/90 px-4 backdrop-blur sm:-mx-6 sm:px-5 lg:-mx-8">
4545 <div className="min-w-0 grow">
4646 <Crumbs items={[{ label: "Artifacts", to: `/${slug}/-/artifacts` }, { label: space.repo }, ...file.path.split("/").map((part) => ({ label: part }))]} />
4747 </div>
+19−0
140140 );
141141 }
142142
143+/** Artifacts' pages that are lists, not one artifact. */
144+const ARTIFACT_LISTS = new Set(["new", "templates", "trash", "stale", "spaces"]);
145+
146+/** Whether the page (its path after `/-/`, in parts) is one artifact or a project's doc file. */
147+function isArtifactPage(parts: string[]): boolean {
148+ if (parts[0] !== "artifacts" || parts.length < 2) return false;
149+ if (parts[1] === "repo") return true;
150+ return !ARTIFACT_LISTS.has(parts[1]!) && parts.length === 2;
151+}
152+
143153 export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
144154 const { workspace, role, welcome } = loaderData;
145155 const { pathname } = useLocation();
158168 if (parts[0] === "chat" && !next) return <Outlet />;
159169 // Code's Overview lays itself out, as Mission control did.
160170 if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />;
171+ // An open artifact, or a project's doc, fills the panel: its header runs edge to edge right under
172+ // the top bar (components/folios/shell.tsx), and its page sets its own width.
173+ if (isArtifactPage(parts) && !next) {
174+ return (
175+ <div className="px-4 pb-10 sm:px-6 lg:px-8">
176+ <Outlet />
177+ </div>
178+ );
179+ }
161180 if (shown !== "overview") {
162181 const key = shown ?? parts[0] ?? "";
163182 const heading = PAGES[key];
+12−8
11 import { ArrowUpRight, Box, ChevronLeft, ChevronRight, LayoutGrid, List, Lock, Rocket, Search } from "lucide-react";
2−import { type KeyboardEvent, useEffect, useRef } from "react";
2+import { type KeyboardEvent, useEffect, useRef, useState } from "react";
33 import { Form, Link, useLocation, useNavigation, useSubmit } from "react-router";
44
55 import type { Route } from "./+types/projects";
66 import { host } from "../../components/deploy";
77 import { PinButton } from "../../components/pin-button";
88 import { EmptyState, Pill, TimeAgo, notACredential } from "../../components/ui";
9+import { Checkbox } from "../../components/ui/checkbox";
910 import { SelectField } from "../../components/ui/select";
1011 import { page as pageMeta } from "../../lib/meta";
1112 import {
239240 const form = useRef<HTMLFormElement>(null);
240241 const searchBox = useRef<HTMLInputElement>(null);
241242 const typing = useRef<ReturnType<typeof setTimeout> | null>(null);
243+ const deploysBox = useRef<HTMLButtonElement>(null);
244+ const [deploys, setDeploys] = useState(query.deployments);
242245 const member = role != null;
243246 const pins = new Set(pinned);
244247 // A filter or page of this list on its way: the list says it is busy.
268271 const field = fields.namedItem(name) as HTMLInputElement | HTMLSelectElement | null;
269272 if (field && field !== document.activeElement && "value" in field) field.value = value;
270273 }
271− const deploys = fields.namedItem("deployments") as HTMLInputElement | null;
272− if (deploys && deploys !== document.activeElement) deploys.checked = query.deployments;
274+ if (deploysBox.current !== document.activeElement) setDeploys(query.deployments);
273275 }, [query]);
274276
275277 // `/` goes to the search, from anywhere on the page that is not a field.
394396 )}
395397 {member && (
396398 <label className={`${CONTROL} flex cursor-pointer items-center gap-2 select-none`}>
397− <input
398− type="checkbox"
399+ <Checkbox
400+ ref={deploysBox}
399401 name="deployments"
400402 value="on"
401− defaultChecked={query.deployments}
402− onChange={() => apply()}
403− className="accent-[var(--color-accent)]"
403+ checked={deploys}
404+ onCheckedChange={(checked) => {
405+ setDeploys(checked === true);
406+ apply();
407+ }}
404408 />
405409 Deploys
406410 <span className="text-xs tabular-nums text-faint">{facets.deploying}</span>
+25−0
1717 else if (block.t === "heading") walk(block.c);
1818 else if (block.t === "quote") block.c.forEach(each);
1919 else if (block.t === "list") block.items.forEach((item) => item.forEach(each));
20+ else if (block.t === "table") [block.head, ...block.rows].forEach((row) => row.forEach(walk));
2021 };
2122 list.forEach(each);
2223 return out;
173174 assert.equal(plainText("x".repeat(300), 140).length, 140);
174175 assert.ok(plainText("x".repeat(300), 140).endsWith("…"));
175176 });
177+
178+test("tables: a header, a --- row with its alignment, then rows", () => {
179+ const [table, after] = blocks("| Session | Length | Notes |\n| --- | ---: | :-: |\n| 1. Welcome | 45 min | `web` \\| `api` |\n| 2. Queue | 30 min |\n\nAfter.");
180+ assert.ok(table!.t === "table");
181+ assert.deepEqual(table.align, [null, "right", "center"]);
182+ assert.deepEqual(table.head, [[{ t: "text", v: "Session" }], [{ t: "text", v: "Length" }], [{ t: "text", v: "Notes" }]]);
183+ assert.equal(table.rows.length, 2);
184+ // An escaped pipe stays in its cell; a short row is filled to the header's width.
185+ assert.deepEqual(table.rows[0]![2], [{ t: "code", v: "web" }, { t: "text", v: " | " }, { t: "code", v: "api" }]);
186+ assert.deepEqual(table.rows[1]![2], []);
187+ assert.equal(after!.t, "p");
188+ // Without outer pipes, and right after a paragraph.
189+ assert.deepEqual(blocks("Plan:\na | b\n--|--\n1 | 2").map((b) => b.t), ["p", "table"]);
190+ // A pipe inside backticks is not a column.
191+ const [code] = blocks("| a |\n| - |\n| `x | y` |");
192+ assert.ok(code!.t === "table" && code.rows[0]!.length === 1);
193+ // Not tables: a rule under text, columns that don't match, a line with a pipe alone.
194+ assert.deepEqual(blocks("a | b\n---").map((b) => b.t), ["p", "hr"]);
195+ assert.deepEqual(blocks("| a | b |\n| --- |\n| 1 | 2 |").map((b) => b.t), ["p"]);
196+ assert.deepEqual(blocks("either | or").map((b) => b.t), ["p"]);
197+ // Links in cells are checked like any other.
198+ assert.deepEqual(links(blocks("| x |\n| - |\n| [ok](https://x.example) [no](javascript:alert(1)) |")), ["https://x.example"]);
199+ assert.equal(plainText("| a | b |\n|---|---|\n| 1 | 2 |"), "a · b 1 · 2");
200+});
+79−4
77 *
88 * The dialect is what people and agents write in chat: bold, italic,
99 * strikethrough (`~~x~~` or `~x~`), inline and fenced code, links and bare
10− * URLs, lists (nested by indenting), quotes, headings and rules, plus what
10+ * URLs, lists (nested by indenting), quotes, headings, rules and tables
11+ * (a header row, a `---` row with optional `:` alignment, then rows), plus what
1112 * g1t adds: `@mentions`, `#channels` and `#123` references. A line break
1213 * is kept where it was typed.
1314 */
2930 | { t: "code"; lang: string | null; v: string }
3031 | { t: "list"; ordered: boolean; start: number; items: Block[][] }
3132 | { t: "quote"; c: Block[] }
32− | { t: "hr" };
33+ | { t: "hr" }
34+ | { t: "table"; align: Align[]; head: Span[][]; rows: Span[][][] };
35+
36+/** How a table's column lines up, from its `---` row: `:--`, `:-:` or `--:`. */
37+export type Align = "left" | "center" | "right" | null;
3338
3439 /** Where a link may go: the web, mail, or a page on this site. */
3540 export function safeHref(href: string): string | null {
152157 const HEADED = /^ {0,3}#{1,6}[ \t]+\S/;
153158 const ITEM = /^([ \t]*)([-*+•]|\d{1,9}[.)])(?:([ \t]+)(.*)|[ \t]*$)/;
154159
160+/** A table's `---` row: cells of dashes, each with an optional `:` either side. */
161+const DELIMITER = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
162+
163+/**
164+ * A table row's cells: split on `|`, but not on `\|` or a `|` inside
165+ * backticks, with the pipes at either end optional.
166+ */
167+function cells(line: string): string[] {
168+ let text = line.trim();
169+ if (text.startsWith("|")) text = text.slice(1);
170+ if (text.endsWith("|") && !text.endsWith("\\|")) text = text.slice(0, -1);
171+ const out: string[] = [];
172+ let cell = "";
173+ let ticks = 0;
174+ for (let i = 0; i < text.length; i++) {
175+ const char = text[i]!;
176+ if (char === "\\" && text[i + 1] === "|") {
177+ cell += "|";
178+ i++;
179+ } else if (char === "`") {
180+ let run = 1;
181+ while (text[i + run] === "`") run++;
182+ ticks = ticks === 0 ? run : ticks === run ? 0 : ticks;
183+ cell += "`".repeat(run);
184+ i += run - 1;
185+ } else if (char === "|" && ticks === 0) {
186+ out.push(cell.trim());
187+ cell = "";
188+ } else cell += char;
189+ }
190+ out.push(cell.trim());
191+ return out;
192+}
193+
194+/** Whether `line` and the one after it start a table: a row with a pipe, then a `---` row as wide. */
195+function tableAt(lines: string[], i: number): boolean {
196+ const head = lines[i]!;
197+ const rule = lines[i + 1];
198+ if (rule == null || !head.includes("|") || !rule.includes("-") || !DELIMITER.test(rule)) return false;
199+ // One column needs its pipes, so a `---` under a line of text stays a paragraph and a rule.
200+ if (!rule.includes("|") && !head.trim().startsWith("|")) return false;
201+ return cells(head).length === cells(rule).length;
202+}
203+
204+function alignOf(cell: string): Align {
205+ const left = cell.startsWith(":");
206+ const right = cell.endsWith(":");
207+ return left && right ? "center" : right ? "right" : left ? "left" : null;
208+}
209+
155210 function indentOf(line: string): number {
156211 let width = 0;
157212 for (const char of line) {
204259 i++;
205260 continue;
206261 }
262+ if (tableAt(lines, i)) {
263+ flush();
264+ const align = cells(lines[i + 1]!).map(alignOf);
265+ const width = align.length;
266+ // Every row has the header's columns: cut what is beyond, fill what is missing.
267+ const row = (text: string) => {
268+ const list = cells(text).slice(0, width);
269+ while (list.length < width) list.push("");
270+ return list.map((cell) => inline(cell));
271+ };
272+ const head = row(line);
273+ const rows: Span[][][] = [];
274+ i += 2;
275+ while (i < lines.length && lines[i]!.trim() !== "" && lines[i]!.includes("|") && !startsBlock(lines[i]!)) rows.push(row(lines[i++]!));
276+ out.push({ t: "table", align, head, rows });
277+ continue;
278+ }
207279 const fence = FENCE.exec(line);
208280 if (fence) {
209281 flush();
278350 out.push({ t: "list", ordered, start, items });
279351 continue;
280352 }
281− if (paragraph.length && startsBlock(line)) flush();
353+ if (paragraph.length && (startsBlock(line) || tableAt(lines, i))) flush();
282354 paragraph.push(inline(lineText(line)));
283355 i++;
284356 }
286358 return out;
287359 }
288360
289−/** A message's whole text as blocks: paragraphs, headings, code, lists, quotes and rules. */
361+/** A message's whole text as blocks: paragraphs, headings, code, lists, quotes, rules and tables. */
290362 export function blocks(text: string): Block[] {
291363 return parse(text.replace(/\r\n?/g, "\n").split("\n"), 0);
292364 }
342414 break;
343415 case "hr":
344416 break;
417+ case "table":
418+ for (const row of [block.head, ...block.rows]) out.push(row.map(spansText).join(" · "));
419+ break;
345420 }
346421 }
347422 return out.filter((line) => line.trim() !== "");