Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

292 linesCodeBlameRaw
1/**
2 * sudo's building blocks, after apps/web/app/components/ui. None of them
3 * sets a `style` attribute: the content security policy allows no inline
4 * styles, so sizes and colours that vary are drawn as SVG attributes.
5 */
6import { AlertTriangle, CheckCircle2, ChevronDown, Info } from "lucide-react";
7import type { ComponentProps, ReactNode } from "react";
8import { Link, type LinkProps, useRouteLoaderData } from "react-router";
9
10import type { Limit, Terms, Trust } from "@g1t/contracts";
11
12import { usd } from "~/lib/money";
13import { fullDiscount, termsLabel } from "~/lib/terms";
14import { when } from "~/lib/time";
15
16export function Field({ label, hint, children, className = "" }: { label: string; hint?: ReactNode; children: ReactNode; className?: string }) {
17 return (
18 <label className={`block ${className}`}>
19 <span className="mb-1.5 block text-sm font-medium text-muted">{label}</span>
20 {children}
21 {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
22 </label>
23 );
24}
25
26const CONTROL =
27 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent/60";
28
29/** Tells password managers that a field is not a login. */
30const NOT_A_CREDENTIAL = {
31 autoComplete: "off",
32 "data-1p-ignore": true,
33 "data-lpignore": "true",
34 "data-bwignore": true,
35 "data-form-type": "other",
36};
37
38export function Input({ className = "", ...props }: ComponentProps<"input">) {
39 return <input {...NOT_A_CREDENTIAL} {...props} className={`${CONTROL} ${className}`} />;
40}
41
42export function Textarea({ className = "", ...props }: ComponentProps<"textarea">) {
43 return <textarea {...NOT_A_CREDENTIAL} {...props} className={`${CONTROL} ${className}`} />;
44}
45
46/** Classes that size or place the control, which belong on its wrapper. */
47const LAYOUT = /^(?:[a-z0-9]+:)*(?:w-|min-w-|max-w-|flex-|shrink|grow|basis-|self-|col-|row-|order-|m[trblxy]?-)/;
48
49/**
50 * A native select drawn as shadcn's NativeSelect: the browser's own arrow
51 * hidden, a chevron of ours in its place. Native, so it opens, types to
52 * search and works on phones before any script has loaded.
53 */
54export function Select({ className = "", ...props }: ComponentProps<"select">) {
55 const classes = className.split(/\s+/).filter(Boolean);
56 const outer = classes.filter((name) => LAYOUT.test(name)).join(" ");
57 const inner = classes.filter((name) => !LAYOUT.test(name)).join(" ");
58 return (
59 <span className={`relative block w-full ${outer}`}>
60 <select {...props} className={`${CONTROL} cursor-pointer appearance-none pr-9 ${inner}`} />
61 <ChevronDown aria-hidden className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted" />
62 </span>
63 );
64}
65
66type Variant = "primary" | "quiet" | "danger" | "lavender";
67
68const BUTTON_BASE =
69 "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium whitespace-nowrap transition-colors disabled:opacity-50";
70
71const BUTTON_VARIANTS: Record<Variant, string> = {
72 primary: "bg-fg text-bg hover:bg-fg-hover",
73 lavender: "bg-accent text-bg hover:bg-accent-hover",
74 quiet: "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
75 danger: "border border-danger/40 text-danger hover:border-danger/70 hover:bg-danger/10",
76};
77
78export function Button({ variant = "primary", className = "", ...props }: ComponentProps<"button"> & { variant?: Variant }) {
79 return <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${className}`} />;
80}
81
82export function ButtonLink({ variant = "primary", className = "", ...props }: LinkProps & { variant?: Variant }) {
83 return <Link {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${className}`} />;
84}
85
86/** A bordered panel with a heading. */
87export function Section({
88 id,
89 title,
90 description,
91 actions,
92 children,
93 className = "",
94}: {
95 id?: string;
96 title: string;
97 description?: ReactNode;
98 actions?: ReactNode;
99 children: ReactNode;
100 className?: string;
101}) {
102 return (
103 <section id={id} className={`scroll-mt-20 rounded-lg border border-line bg-surface ${className}`}>
104 <header className="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
105 <div>
106 <h2 className="text-[0.9375rem] font-semibold tracking-tight">{title}</h2>
107 {description && <p className="mt-0.5 text-sm text-muted">{description}</p>}
108 </div>
109 {actions}
110 </header>
111 <div className="p-4 sm:p-5">{children}</div>
112 </section>
113 );
114}
115
116export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
117 return (
118 <div className="rounded-lg border border-dashed border-line px-6 py-10 text-center">
119 <p className="font-medium">{title}</p>
120 {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
121 </div>
122 );
123}
124
125const NOTICE = {
126 ok: { icon: CheckCircle2, className: "border-success/30 bg-success/8 text-success" },
127 error: { icon: AlertTriangle, className: "border-danger/30 bg-danger/8 text-danger" },
128 warn: { icon: AlertTriangle, className: "border-warn/30 bg-warn/8 text-warn" },
129 info: { icon: Info, className: "border-accent/30 bg-accent/8 text-accent" },
130} as const;
131
132export function Notice({ tone, children }: { tone: keyof typeof NOTICE; children: ReactNode }) {
133 const { icon: Icon, className } = NOTICE[tone];
134 return (
135 <div role={tone === "error" ? "alert" : "status"} className={`flex gap-2.5 rounded-md border px-3.5 py-2.5 text-sm ${className}`}>
136 <Icon size={16} className="mt-0.5 shrink-0" />
137 <div className="min-w-0 text-fg-soft">{children}</div>
138 </div>
139 );
140}
141
142/** A small label; `tone` colours it. */
143export function Badge({ tone = "plain", children }: { tone?: "plain" | "lavender" | "mint" | "warn" | "danger" | "info"; children: ReactNode }) {
144 const tones = {
145 plain: "border-line text-muted",
146 lavender: "border-accent/35 bg-accent/10 text-accent",
147 mint: "border-success/30 bg-success/8 text-success",
148 warn: "border-warn/35 bg-warn/10 text-warn",
149 danger: "border-danger/35 bg-danger/10 text-danger",
150 info: "border-info/35 bg-info/10 text-info",
151 };
152 return <span className={`inline-flex items-center rounded-full border px-2 py-px text-xs font-medium whitespace-nowrap ${tones[tone]}`}>{children}</span>;
153}
154
155export function TermsBadge({ terms }: { terms: Terms }) {
156 if (fullDiscount(terms)) return <Badge tone="mint">100% discount</Badge>;
157 if (terms.kind === "standard") return <Badge>Standard</Badge>;
158 return <Badge tone="info">{termsLabel(terms)}</Badge>;
159}
160
161const TRUST: Record<Trust, { label: string; tone: "plain" | "lavender" | "mint" | "info"; about: string }> = {
162 new: { label: "New", tone: "plain", about: "New: has not paid g1t yet, so the limit is small." },
163 paid: { label: "Paid", tone: "info", about: "Paid: has paid g1t, and the limit grows with what it has paid." },
164 established: {
165 label: "Established",
166 tone: "mint",
167 about: "Established: the limit follows their monthly spend, up to $10,000.",
168 },
169 reviewed: { label: "Reviewed", tone: "mint", about: "Reviewed: g1t set the limit by hand, after talking to them." },
170 // A 100% discount: nothing is charged, so no limit on unpaid usage.
171 internal: { label: "100% discount", tone: "lavender", about: "A 100% discount: nothing is charged, so there is no limit on unpaid usage." },
172};
173
174export function TrustBadge({ trust }: { trust: Trust }) {
175 const { label, tone, about } = TRUST[trust] ?? { label: trust, tone: "plain", about: "" };
176 return (
177 <span title={about || undefined}>
178 <Badge tone={tone}>{label}</Badge>
179 </span>
180 );
181}
182
183/** What a trust level means for the limit, in a sentence. */
184export function trustAbout(trust: Trust): string {
185 return TRUST[trust]?.about ?? "";
186}
187
188/** A page's title, a line about it, and anything to its right. */
189export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
190 return (
191 <div className="flex flex-wrap items-start justify-between gap-4">
192 <div className="min-w-0">
193 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
194 {description && <p className="mt-1 text-sm text-muted">{description}</p>}
195 </div>
196 {actions}
197 </div>
198 );
199}
200
201const STATE = {
202 ok: { label: "OK", fill: "var(--color-success)", text: "text-success" },
203 warning: { label: "Warning", fill: "var(--color-warn)", text: "text-warn" },
204 stopped: { label: "Stopped", fill: "var(--color-danger)", text: "text-danger" },
205} as const;
206
207export function StateBadge({ state }: { state: Limit["state"] }) {
208 const tone = state === "stopped" ? "danger" : state === "warning" ? "warn" : "mint";
209 return <Badge tone={tone}>{STATE[state].label}</Badge>;
210}
211
212/**
213 * Usage this month that is not paid for yet, against the limit, as a bar
214 * coloured by where it stands. With no limit (comped) it shows the figure.
215 */
216export function ExposureBar({ limit, wide = false }: { limit: Limit; wide?: boolean }) {
217 const { exposureMicros, ceilingMicros, state } = limit;
218 const share = ceilingMicros && ceilingMicros > 0 ? Math.min(1, Math.max(0, exposureMicros / ceilingMicros)) : 0;
219 const percent = ceilingMicros && ceilingMicros > 0 ? Math.round((exposureMicros / ceilingMicros) * 100) : null;
220 return (
221 <div className={wide ? "w-full" : "w-40 max-w-full"}>
222 <div className="flex items-baseline justify-between gap-2 text-xs">
223 <span className="tabular font-medium text-fg-soft">
224 {usd(exposureMicros)}
225 <span className="font-normal text-faint"> / {ceilingMicros == null ? "no limit" : usd(ceilingMicros)}</span>
226 </span>
227 {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>}
228 </div>
229 <svg
230 viewBox="0 0 100 4"
231 preserveAspectRatio="none"
232 className="mt-1.5 block h-1.5 w-full"
233 role="img"
234 aria-label={`${STATE[state].label}: ${percent ?? 0}% of the limit`}
235 >
236 <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--color-raised)" />
237 {ceilingMicros != null && share > 0 && <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} />}
238 </svg>
239 </div>
240 );
241}
242
243/** A label and a figure, for the strip of totals over a page. */
244export function Stat({ label, value, hint, tone }: { label: string; value: ReactNode; hint?: ReactNode; tone?: "danger" | "warn" | "mint" }) {
245 const color = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "mint" ? "text-success" : "text-fg";
246 return (
247 <div className="rounded-lg border border-line bg-surface px-4 py-3">
248 <p className="text-xs text-muted">{label}</p>
249 <p className={`tabular mt-1 text-lg font-semibold tracking-tight ${color}`}>{value}</p>
250 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
251 </div>
252 );
253}
254
255/** The zone the pages say times in, from the root loader (lib/time.ts); UTC without one. */
256export function useZone(): string {
257 return useRouteLoaderData<{ zone?: string }>("root")?.zone ?? "UTC";
258}
259
260/**
261 * A timestamp in the staff member's zone, with the zone's abbreviation;
262 * the UTC instant in `datetime` and on hover, for comparing notes.
263 */
264export function When({ at, time = false }: { at: string | null | undefined; time?: boolean }) {
265 const zone = useZone();
266 if (!at) return <span className="text-faint">—</span>;
267 const date = new Date(at);
268 if (Number.isNaN(date.getTime())) return <span>{at}</span>;
269 const shown = when(date, zone, time);
270 return (
271 <time dateTime={date.toISOString()} title={shown.utc}>
272 {shown.text}
273 {shown.zone && <span className="text-faint"> {shown.zone}</span>}
274 </time>
275 );
276}
277
278/** A letter avatar, drawn as SVG so its colour needs no inline style. */
279export function Avatar({ name, size = 20, square = true }: { name: string; size?: number; square?: boolean }) {
280 const hues = [82, 200, 262, 28, 330, 160];
281 let hash = 0;
282 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
283 const hue = hues[Math.abs(hash) % hues.length];
284 return (
285 <svg width={size} height={size} viewBox="0 0 20 20" aria-hidden="true" className="shrink-0">
286 <rect width="20" height="20" rx={square ? 5 : 10} fill={`oklch(0.4 0.09 ${hue})`} />
287 <text x="10" y="14.2" textAnchor="middle" fontSize="11" fontWeight="600" fontFamily="var(--g1t-font-mono)" fill={`oklch(0.93 0.08 ${hue})`}>
288 {(name[0] ?? "?").toUpperCase()}
289 </text>
290 </svg>
291 );
292}