Skip to content
763 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
2 * The pages at status.g1t.sh, drawn on the server as plain HTML: no
3 * framework, a small stylesheet inline, and a few lines of script (served
4 * at /status.js) that only add hover and keyboard detail to the bars and
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.5 * say times in the browser's own zone, and the Auto, Light and Dark switch
6 * (with /theme.js, which draws the reader's choice before the first
7 * paint; on Auto, and with no script, the page follows the system's light
8 * or dark setting). Without it, times are already in
Fast pages, required checks on the branch, self-hosted runners, honest incidents9 * the reader's zone as Cloudflare places them (PageOptions `zone`), with
10 * the zone's abbreviation; `datetime` attributes and feeds stay in UTC. Every page works without it:
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas11 * subscribing, confirming and leaving are plain forms. No Workers
12 * imports, so it is tested under Node.
13 *
14 * / the status now, maintenance, recent incidents, subscribing
15 * /incidents/<id> one incident's every public update, and its postmortem
16 * /maintenance/<id> one maintenance window and its updates
17 * /history past incidents and maintenance, by month
18 * /subscribe subscribing by email, to every part or some
19 */
20import type { PostmortemFields, StatusComponent, StatusIncident, StatusMaintenance, StatusOverallState } from "@g1t/contracts";
21
22import { type DayBar, HISTORY_DAYS, IMPACT_WORD, INCIDENT_STATUS, type PageModel, maintenanceState, percent } from "./model.ts";
Fast pages, required checks on the branch, self-hosted runners, honest incidents23import { timeIn, validZone } from "./time.ts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas24
25export type PageOptions = {
26 /** The site the page is about: `https://g1t.sh`. */
27 siteUrl: string;
28 /** Where help is: `https://g1t.sh/support`. */
29 supportUrl: string;
30 /** The share card, or empty for none. */
31 ogImage: string;
32 /** This page's own address, for its canonical link. */
33 selfUrl: string;
34 now: Date;
35 /** Whether email subscriptions can be offered (a sender and a secret). */
36 email?: boolean;
Fast pages, required checks on the branch, self-hosted runners, honest incidents37 /** The reader's time zone (time.ts `readZone`); UTC when absent. */
38 zone?: string;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas39};
40
41export function escape(text: string): string {
42 return text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
43}
44
45const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
46const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
47
48/** "5 Oct 2026". */
49export function dayLabel(day: string): string {
50 const [y, m, d] = day.split("-").map(Number);
51 return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
52}
53
Fast pages, required checks on the branch, self-hosted runners, honest incidents54/**
55 * The zone the page being drawn says its times in. Each page's renderer
56 * sets it from its options before drawing (drawing is synchronous, so one
57 * page never sees another's).
58 */
59let zone = "UTC";
60
61function zoned(options: PageOptions): void {
62 zone = validZone(options.zone) ? options.zone : "UTC";
63}
64
65/** "5 Oct 2026, 14:03 UTC", or in the reader's zone: "5 Oct 2026, 07:03 PDT". */
66export function timeLabel(at: string, tz = zone): string {
67 return timeIn(at, tz);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas68}
69
Fast pages, required checks on the branch, self-hosted runners, honest incidents70/** A time in the reader's zone; the UTC instant in `datetime` and, in words, on hover. */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas71function time(at: string, relative = false): string {
Fast pages, required checks on the branch, self-hosted runners, honest incidents72 return `<time datetime="${escape(at)}" title="${escape(timeIn(at, "UTC"))}"${relative ? " data-relative" : ""}>${escape(timeLabel(at))}</time>`;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas73}
74
75/** "1h 05m", "3d 4h". */
76export function lasted(fromIso: string, toIso: string): string {
77 const minutes = Math.max(0, Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60000));
78 if (minutes < 60) return `${minutes} min`;
79 const hours = Math.floor(minutes / 60);
80 if (hours < 48) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
81 return `${Math.floor(hours / 24)}d ${hours % 24}h`;
82}
83
84/** What a day's square says on hover, and to a screen reader. */
85export function dayDetail(bar: DayBar): string {
86 const parts = [dayLabel(bar.day)];
87 if (bar.checks === 0) parts.push("No data");
88 else {
89 parts.push(`${percent(bar.uptime)} answered`);
90 if (bar.failed > 0) parts.push(`${bar.failed} failed check${bar.failed === 1 ? "" : "s"} of ${bar.checks}`);
91 if (bar.slow > 0) parts.push(`${bar.slow} slow`);
92 if (bar.avg_ms != null) parts.push(`average ${bar.avg_ms} ms`);
93 }
94 for (const title of bar.incidents) parts.push(`Incident: ${title}`);
95 return parts.join(" · ");
96}
97
98export const STATE_WORD: Record<StatusComponent["state"], string> = {
99 up: "Operational",
100 degraded: "Degraded",
101 partial: "Partial outage",
102 down: "Outage",
103 maintenance: "Under maintenance",
104 unmonitored: "Not monitored",
105};
106
107/** The pixel G1T: 5×7 capitals, the 1 in lavender, as in the site's logo. */
108const G = [".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."];
109const ONE = ["..#..", ".##..", "#.#..", "..#..", "..#..", "..#..", "#####"];
110const T = ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."];
111
112function pixels(rows: string[], dx: number, cls: string): string {
113 return rows
114 .flatMap((row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${(dx + x) * 10 + 1}" y="${y * 10 + 1}" width="8" height="8"${cls}/>`] : [])))
115 .join("");
116}
117
118export const LOGO = `<svg class="logo" viewBox="1 1 148 68" role="img" aria-label="g1t">${pixels(G, 0, "")}${pixels(ONE, 6, ' class="one"')}${pixels(T, 10, "")}</svg>`;
119
120/** The favicon: the pixel 1. */
121export const FAVICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges"><style>rect{fill:#6b56e8}@media (prefers-color-scheme: dark){rect{fill:#b6a8ff}}</style>${ONE.flatMap(
122 (row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${x * 2 + 3}" y="${y * 2 + 1}" width="2" height="2"/>`] : [])),
123).join("")}</svg>`;
124
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.125/** Where this browser keeps the reader's theme: "light", "dark", or nothing for Auto. */
126export const THEME_KEY = "g1t-status-theme";
127
128/**
129 * The page's theme before its first paint (served at /theme.js, loaded
130 * without defer in the head): the reader's choice from this browser, or
131 * "auto", which the stylesheet resolves by the system's setting. Without
132 * scripts the page follows the system and the switch stays hidden.
133 */
134export const THEME_SCRIPT = `(() => {
135 let theme = "";
136 try { theme = localStorage.getItem(${JSON.stringify(THEME_KEY)}) || ""; } catch {}
137 document.documentElement.dataset.theme = theme === "light" || theme === "dark" ? theme : "auto";
138})();
139`;
140
141const ICON = (body: string) =>
142 `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
143
144const THEMES = [
145 { value: "auto", label: "Auto: follow the system", icon: ICON('<rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8M12 17v4"/>') },
146 {
147 value: "light",
148 label: "Light",
149 icon: ICON('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2m-7.07-17.07 1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>'),
150 },
151 { value: "dark", label: "Dark", icon: ICON('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>') },
152];
153
154/** Auto, Light and Dark, in the header; status.js keeps the choice in this browser. */
155const THEME_SWITCH = `<div class="theme" role="group" aria-label="Theme">${THEMES.map(
156 (t) => `<button type="button" data-theme-choice="${t.value}" aria-label="${t.label}" aria-pressed="false">${t.icon}</button>`,
157).join("")}</div>`;
158
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas159function bar(component: StatusComponent, days: DayBar[]): string {
160 if (component.check_state === "unmonitored" && component.uptime_90d == null) {
161 return `<p class="unchecked">${escape(component.checks)}</p>`;
162 }
163 const cells = days
164 .map((d) => {
165 const detail = dayDetail(d);
166 return `<li class="${d.state}" title="${escape(detail)}" data-detail="${escape(detail)}"></li>`;
167 })
168 .join("");
169 const label = `${component.name}: ${percent(component.uptime_90d)} of checks answered over ${HISTORY_DAYS} days. Use the arrow keys for each day.`;
170 return `<div class="history">
171<ol class="bar" tabindex="0" aria-label="${escape(label)}">${cells}</ol>
172<div class="legend"><span><span class="long">${HISTORY_DAYS}</span><span class="short">30</span> days ago</span><span class="uptime">${escape(percent(component.uptime_90d))} uptime</span><span>Today</span></div>
173<p class="detail" aria-live="polite"></p>
174</div>`;
175}
176
177function componentRow(component: StatusComponent, days: DayBar[]): string {
178 const seen = component.check_state !== "unmonitored" ? `<p class="seen">${escape(component.detail)}</p>` : "";
179 return `<li class="component">
180<div class="row">
181<div class="name"><h3>${escape(component.name)}</h3><span class="address">${escape(component.address)}</span></div>
182<div class="state s-${component.state}"><span class="dot ${component.state}" aria-hidden="true"></span>${STATE_WORD[component.state]}</div>
183</div>
184${seen}
185${bar(component, days)}
186</li>`;
187}
188
189/**
190 * Text as HTML: blank lines between paragraphs, and a block whose lines
191 * all start with "- " as a list. Everything is escaped.
192 */
193export function prose(text: string): string {
194 return text
195 .split(/\n{2,}/)
196 .map((block) => {
197 const lines = block.split("\n").filter((l) => l.trim() !== "");
198 if (lines.length && lines.every((l) => /^\s*[-*] /.test(l))) {
199 return `<ul>${lines.map((l) => `<li>${escape(l.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`;
200 }
201 return `<p>${escape(block).replace(/\n/g, "<br>")}</p>`;
202 })
203 .join("");
204}
205
206function impactLine(incident: StatusIncident, names: Map<string, string>): string {
207 return incident.component_impacts
208 .map((c) => `<span class="affects">${escape(names.get(c.key) ?? c.key)} <span class="impact i-${c.impact}">${escape(IMPACT_WORD[c.impact])}</span></span>`)
209 .join("");
210}
211
212function pill(incident: StatusIncident): string {
213 const open = incident.resolved_at == null;
214 const impact = incident.impact === "down" ? "Major outage" : "Partial outage";
215 return `<span class="pill ${open ? incident.impact : "done"}">${open ? `${escape(INCIDENT_STATUS[incident.status])} · ${impact}` : "Resolved"}</span>`;
216}
217
218function updatesList(incident: StatusIncident): string {
219 return `<ol class="updates">${incident.updates
220 .map(
221 (u) =>
222 `<li id="update-${escape(u.id)}"><p class="when"><strong>${escape(INCIDENT_STATUS[u.status])}</strong> ${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`,
223 )
224 .join("")}</ol>`;
225}
226
227/** An incident on the front page: its title links to its own page. */
228function incidentBlock(incident: StatusIncident, names: Map<string, string>): string {
229 const open = incident.resolved_at == null;
230 return `<article class="incident ${open ? `open ${incident.impact}` : "resolved"}">
231<header>
232<h3><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a></h3>
233${pill(incident)}
234</header>
235<p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)}` : ""}</p>
236<div class="impacts">${impactLine(incident, names)}</div>
237${updatesList(incident)}
238</article>`;
239}
240
241/** A line for a past incident: title, when, how long, and its postmortem. */
242function incidentLine(incident: StatusIncident): string {
243 const how = incident.resolved_at ? `lasted ${lasted(incident.started_at, incident.resolved_at)}` : escape(INCIDENT_STATUS[incident.status]);
244 const latest = incident.updates[0];
245 return `<li class="line">
246<div class="line-head"><a href="/incidents/${escape(incident.id)}">${escape(incident.title)}</a>${incident.postmortem_published_at ? `<a class="tag" href="/incidents/${escape(incident.id)}#postmortem">Postmortem</a>` : ""}</div>
247<p class="meta">${time(incident.started_at)} · ${how}</p>
248${latest ? `<p class="last">${escape(latest.text.split("\n")[0]!.slice(0, 220))}</p>` : ""}
249</li>`;
250}
251
252function maintenanceWindow(m: StatusMaintenance): string {
253 return `${time(m.starts_at)} to ${time(m.ends_at)} · ${lasted(m.starts_at, m.ends_at)}`;
254}
255
256const MAINTENANCE_WORD: Record<StatusMaintenance["state"], string> = {
257 scheduled: "Scheduled",
258 in_progress: "In progress",
259 completed: "Completed",
260 cancelled: "Cancelled",
261};
262
263function maintenanceBlock(m: StatusMaintenance, names: Map<string, string>, now: Date): string {
264 const state = maintenanceState(m, now);
265 const latest = m.updates[0];
266 return `<article class="incident maint ${state}">
267<header>
268<h3><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a></h3>
269<span class="pill maint">${MAINTENANCE_WORD[state]}</span>
270</header>
271<p class="meta">${maintenanceWindow(m)}</p>
272<p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p>
273<div class="text">${prose(m.message)}</div>
274${latest && latest.text !== m.message ? `<p class="when latest"><strong>Latest</strong> ${time(latest.at)} · ${escape(latest.text.split("\n")[0]!)}</p>` : ""}
275</article>`;
276}
277
278const BANNER_CLASS: Record<StatusOverallState, string> = { up: "up", degraded: "degraded", down: "down", maintenance: "maintenance", unknown: "unknown" };
279
280type Layout = { title: string; description: string; body: string; live?: boolean; feeds?: boolean };
281
282function layout(options: PageOptions, page: Layout): string {
283 const site = options.siteUrl.replace(/\/+$/, "");
284 const siteHost = site.replace(/^https?:\/\//, "");
285 return `<!doctype html>
286<html lang="en">
287<head>
288<meta charset="utf-8">
289<meta name="viewport" content="width=device-width, initial-scale=1">
290<title>${escape(page.title)}</title>
291<meta name="description" content="${escape(page.description)}">
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.292<meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)">
293<meta name="theme-color" content="#fbfbfa" media="(prefers-color-scheme: light)">
294<meta name="color-scheme" content="dark light">
295<script src="/theme.js"></script>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas296<link rel="icon" href="/favicon.svg" type="image/svg+xml">
297<link rel="canonical" href="${escape(options.selfUrl)}">
298<link rel="alternate" type="application/json" href="/status.json">
299<link rel="alternate" type="application/atom+xml" title="g1t status" href="/feed.xml">
300<link rel="alternate" type="application/feed+json" title="g1t status" href="/feed.json">
301<link rel="preload" href="/fonts/hanken-grotesk.woff2" as="font" type="font/woff2" crossorigin>
302<link rel="preload" href="/fonts/bricolage-grotesque.woff2" as="font" type="font/woff2" crossorigin>
303<meta property="og:site_name" content="g1t">
304<meta property="og:type" content="website">
305<meta property="og:url" content="${escape(options.selfUrl)}">
306<meta property="og:title" content="${escape(page.title)}">
307<meta property="og:description" content="${escape(page.description)}">
308${options.ogImage ? `<meta property="og:image" content="${escape(options.ogImage)}"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="${escape(options.ogImage)}">` : ""}
309<style>${STYLE}</style>
310<script src="/status.js" defer></script>
311</head>
312<body${page.live ? " data-live" : ""}>
313<div class="page">
314<header class="top">
315<a class="brand" href="/" aria-label="g1t status">${LOGO}<span>Status</span></a>
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.316<div class="top-end">
317${THEME_SWITCH}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas318<a class="out" href="${escape(site)}/">${escape(siteHost)} <span aria-hidden="true">→</span></a>
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.319</div>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas320</header>
321<main>
322${page.body}
323</main>
324<footer class="foot">
325<a href="/history">History</a><span aria-hidden="true">·</span><a href="/subscribe">Subscribe</a><span aria-hidden="true">·</span><a href="/feed.xml">Atom feed</a><span aria-hidden="true">·</span><a href="/feed.json">JSON feed</a><span aria-hidden="true">·</span><a href="/status.json">status.json</a><span aria-hidden="true">·</span><a href="${escape(options.supportUrl)}">Support</a><span aria-hidden="true">·</span><a href="${escape(site)}/">${escape(siteHost)}</a>
326</footer>
327</div>
328</body>
329</html>`;
330}
331
332const FEEDS = `the <a href="/feed.xml">Atom feed</a> or the <a href="/feed.json">JSON feed</a>`;
333
334function subscribeForm(options: PageOptions, compact: boolean): string {
335 if (!options.email) return `<p class="muted">Email updates are not available here. Follow ${FEEDS} instead.</p>`;
336 return `<form class="subscribe" method="post" action="/subscribe">
337<label class="sr" for="sub-email">Email address</label>
338<input id="sub-email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com">
339<input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
340<button type="submit">Subscribe</button>
341</form>
342<p class="feeds">Get an email when an incident or planned maintenance is posted${compact ? `, or <a href="/subscribe">choose which parts</a>` : ""}. Or follow ${FEEDS}.</p>`;
343}
344
345/** The front page. */
346export function renderPage(model: PageModel, options: PageOptions): string {
Fast pages, required checks on the branch, self-hosted runners, honest incidents347 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas348 const { report, bars, stale } = model;
349 const { overall, components, incidents, maintenance, checked_at } = report;
350 const names = new Map(components.map((c) => [c.key, c.name]));
351 const open = incidents.filter((i) => i.resolved_at == null);
352 const recentSince = options.now.getTime() - 14 * 24 * 60 * 60 * 1000;
353 const past = incidents.filter((i) => i.resolved_at != null && Date.parse(i.resolved_at) >= recentSince);
354 const inProgress = maintenance.filter((m) => maintenanceState(m, options.now) === "in_progress");
355 const upcoming = maintenance.filter((m) => maintenanceState(m, options.now) === "scheduled");
356 const description = `${overall.title}. Live status of g1t: the website, API, git, MCP, docs, deployments, agents and billing, with 90 days of history.`;
357 const checkedLine = checked_at
358 ? stale
359 ? `The checks have not run since ${time(checked_at, true)}, so what follows may be out of date.`
360 : `Checked ${time(checked_at, true)}, and every minute.`
361 : "Waiting for the first checks.";
362 const body = `<section class="banner ${BANNER_CLASS[overall.state]}" aria-live="polite">
363<span class="dot big ${overall.state}" aria-hidden="true"></span>
364<div>
365<h1>${escape(overall.title)}</h1>
366<p>${overall.state === "up" ? "" : `${escape(overall.line)} `}${checkedLine}</p>
367</div>
368</section>
369${open.length ? `<section class="block"><h2>Happening now</h2>${open.map((i) => incidentBlock(i, names)).join("")}</section>` : ""}
370${inProgress.length ? `<section class="block"><h2>Maintenance in progress</h2>${inProgress.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""}
371${upcoming.length ? `<section class="block"><h2>Upcoming maintenance</h2>${upcoming.map((m) => maintenanceBlock(m, names, options.now)).join("")}</section>` : ""}
372<section class="block">
373<h2>Components</h2>
374<ul class="components">${components.map((c) => componentRow(c, bars[c.key] ?? [])).join("")}</ul>
375</section>
376<section class="block">
377<div class="block-head"><h2>Past incidents</h2><a href="/history">Full history <span aria-hidden="true">→</span></a></div>
378${past.length ? `<ul class="lines">${past.map(incidentLine).join("")}</ul>` : `<p class="empty">No incidents in the last 14 days.</p>`}
379</section>
380<section class="block" id="subscribe">
381<h2>Subscribe to updates</h2>
382<div class="card">${subscribeForm(options, true)}</div>
383</section>
384<section class="block about">
385<h2>About these checks</h2>
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily386<p>Each monitored part is checked every minute, most with one quick request, the same a visitor's would make, from outside g1t; what each check does is listed below. A part that answers in over 1.5 seconds counts as slow (page speed over 800 ms); one that fails, or takes over 5 seconds, as down. A day shows green when at least 99.9% of its checks answered; checks during planned maintenance are not counted. This page runs apart from g1t itself, so it stays up when g1t does not.</p>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas387<dl>${components.map((c) => `<dt>${escape(c.name)}</dt><dd>${escape(c.checks)}</dd>`).join("")}</dl>
388</section>`;
389 return layout(options, { title: `${overall.title} · g1t status`, description, body, live: true });
390}
391
392const POSTMORTEM_SECTIONS: { key: keyof PostmortemFields; title: string }[] = [
393 { key: "summary", title: "Summary" },
394 { key: "impact", title: "Impact" },
395 { key: "timeline", title: "Timeline" },
396 { key: "root_cause", title: "Root cause" },
397 { key: "went_well", title: "What went well" },
398 { key: "went_badly", title: "What went badly" },
399 { key: "action_items", title: "Action items" },
400];
401
402/** A postmortem's sections, those with something in them. */
403export function postmortemHtml(p: PostmortemFields): string {
404 return POSTMORTEM_SECTIONS.filter((s) => p[s.key].trim())
405 .map((s) => `<section class="pm-section"><h3>${s.title}</h3><div class="text">${prose(p[s.key])}</div></section>`)
406 .join("");
407}
408
409/** One incident's page: every public update, oldest last, and its postmortem once published. */
410export function renderIncident(
411 incident: StatusIncident,
412 postmortem: (PostmortemFields & { published_at: string | null }) | null,
413 names: Map<string, string>,
414 options: PageOptions,
415): string {
Fast pages, required checks on the branch, self-hosted runners, honest incidents416 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas417 const open = incident.resolved_at == null;
418 const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p>
419<article class="incident-page">
420<header class="page-head">
421<h1>${escape(incident.title)}</h1>
422${pill(incident)}
423</header>
424<p class="meta">Started ${time(incident.started_at)}${incident.resolved_at ? ` · resolved ${time(incident.resolved_at)} · lasted ${lasted(incident.started_at, incident.resolved_at)}` : ""}</p>
425<div class="impacts">${impactLine(incident, names)}</div>
426${postmortem?.published_at ? `<p class="note">A postmortem was published ${time(postmortem.published_at)}. <a href="#postmortem">Read it</a>.</p>` : ""}
427<section class="block"><h2>Updates</h2><div class="card">${updatesList(incident)}</div></section>
428${
429 postmortem?.published_at
430 ? `<section class="block" id="postmortem"><h2>Postmortem</h2><div class="card postmortem">${postmortemHtml(postmortem)}</div></section>`
431 : !open
432 ? `<p class="muted">If a postmortem is written for this incident, it will appear on this page.</p>`
433 : ""
434}
435</article>`;
436 return layout(options, {
437 title: `${incident.title} · g1t status`,
438 description: `${open ? INCIDENT_STATUS[incident.status] : "Resolved"}: ${incident.title}. ${incident.updates[0]?.text.slice(0, 140) ?? ""}`,
439 body,
440 });
441}
442
443export function renderMaintenance(m: StatusMaintenance, names: Map<string, string>, options: PageOptions): string {
Fast pages, required checks on the branch, self-hosted runners, honest incidents444 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas445 const state = maintenanceState(m, options.now);
446 const body = `<p class="crumbs"><a href="/">Status</a> <span aria-hidden="true">/</span> <a href="/history">History</a></p>
447<header class="page-head"><h1>${escape(m.title)}</h1><span class="pill maint">${MAINTENANCE_WORD[state]}</span></header>
448<p class="meta">${maintenanceWindow(m)}</p>
449<p class="meta">Affects ${escape(m.components.map((k) => names.get(k) ?? k).join(", "))}</p>
450<section class="block"><h2>Updates</h2><div class="card"><ol class="updates">${m.updates
451 .map((u) => `<li id="update-${escape(u.id)}"><p class="when">${time(u.at)}</p><div class="text">${prose(u.text)}</div></li>`)
452 .join("")}</ol></div></section>`;
453 return layout(options, { title: `${m.title} · g1t status`, description: `Planned maintenance: ${m.title}.`, body });
454}
455
456/** Months, newest first, each with its incidents and maintenance. */
457export function byMonth(incidents: StatusIncident[], maintenance: StatusMaintenance[]): { month: string; incidents: StatusIncident[]; maintenance: StatusMaintenance[] }[] {
458 const months = new Map<string, { incidents: StatusIncident[]; maintenance: StatusMaintenance[] }>();
459 const slot = (month: string) => months.get(month) ?? months.set(month, { incidents: [], maintenance: [] }).get(month)!;
460 for (const i of incidents) slot(i.started_at.slice(0, 7)).incidents.push(i);
461 for (const m of maintenance) slot(m.starts_at.slice(0, 7)).maintenance.push(m);
462 return [...months]
463 .sort((a, b) => b[0].localeCompare(a[0]))
464 .map(([month, v]) => ({
465 month,
466 incidents: v.incidents.sort((a, b) => b.started_at.localeCompare(a.started_at)),
467 maintenance: v.maintenance.sort((a, b) => b.starts_at.localeCompare(a.starts_at)),
468 }));
469}
470
471export function renderHistory(incidents: StatusIncident[], maintenance: StatusMaintenance[], options: PageOptions, months = 12): string {
Fast pages, required checks on the branch, self-hosted runners, honest incidents472 zoned(options);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas473 const groups = byMonth(incidents, maintenance);
474 const now = options.now;
475 const list: string[] = [];
476 for (let i = 0; i < months; i++) {
477 const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
478 const key = d.toISOString().slice(0, 7);
479 const group = groups.find((g) => g.month === key);
480 const items = [
481 ...(group?.incidents.map(incidentLine) ?? []),
482 ...(group?.maintenance.map(
483 (m) =>
484 `<li class="line"><div class="line-head"><a href="/maintenance/${escape(m.id)}">${escape(m.title)}</a><span class="tag">Maintenance · ${MAINTENANCE_WORD[maintenanceState(m, now)]}</span></div><p class="meta">${maintenanceWindow(m)}</p></li>`,
485 ) ?? []),
486 ];
487 list.push(`<section class="month"><h2>${MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCFullYear()}</h2>${items.length ? `<ul class="lines">${items.join("")}</ul>` : `<p class="none">No incidents or maintenance.</p>`}</section>`);
488 }
489 const body = `<p class="crumbs"><a href="/">Status</a></p>
490<header class="page-head"><h1>History</h1></header>
491<p class="muted">Every incident and planned maintenance of the last ${months} months, newest first.</p>
492${list.join("")}`;
493 return layout(options, { title: "History · g1t status", description: "Past incidents and maintenance on g1t, by month.", body });
494}
495
496export function renderSubscribe(options: PageOptions, parts: { key: string; name: string }[]): string {
497 const form = options.email
498 ? `<form class="stack" method="post" action="/subscribe">
499<label for="email">Email address</label>
500<input id="email" type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com">
501<input class="trap" type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
502<fieldset>
503<legend>Which parts</legend>
504<p class="muted">Leave every box clear to hear about everything.</p>
505<div class="checks">${parts.map((p) => `<label class="check"><input type="checkbox" name="components" value="${escape(p.key)}"> ${escape(p.name)}</label>`).join("")}</div>
506</fieldset>
507<button type="submit">Subscribe</button>
508<p class="muted">We email you a link to confirm first. Every email has a link to unsubscribe. Your address is used for nothing else.</p>
509</form>`
510 : `<p class="muted">Email updates are not available on this installation.</p>`;
511 const body = `<p class="crumbs"><a href="/">Status</a></p>
512<header class="page-head"><h1>Subscribe to updates</h1></header>
513<div class="card">${form}</div>
514<section class="block"><h2>Feeds</h2><div class="card"><p class="muted">Every public update and maintenance notice, newest first: the <a href="/feed.xml">Atom feed</a> for feed readers and chat apps, the <a href="/feed.json">JSON feed</a>, and <a href="/status.json">status.json</a> for the state right now.</p></div></section>`;
515 return layout(options, { title: "Subscribe · g1t status", description: "Get an email when g1t posts an incident or planned maintenance.", body });
516}
517
518/** A short page: a heading, a line, and perhaps a form with one button. */
519export function renderMessage(
520 options: PageOptions,
521 input: { title: string; text: string; form?: { action: string; fields: Record<string, string>; button: string } },
522): string {
523 const form = input.form
524 ? `<form method="post" action="${escape(input.form.action)}">${Object.entries(input.form.fields)
525 .map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
526 .join("")}<button type="submit">${escape(input.form.button)}</button></form>`
527 : `<p><a href="/">Back to the status page</a></p>`;
528 const body = `<p class="crumbs"><a href="/">Status</a></p>
529<section class="card message"><h1>${escape(input.title)}</h1><p>${escape(input.text)}</p>${form}</section>`;
530 return layout(options, { title: `${input.title} · g1t status`, description: input.text, body });
531}
532
533/** A small badge: "g1t | all systems normal", coloured by state. */
534export function renderBadge(state: StatusOverallState, title: string): string {
535 const color = { up: "#86efc4", degraded: "#ffbd8c", down: "#ff8394", maintenance: "#8ec5ff", unknown: "#86868e" }[state];
536 const label = "g1t";
537 const value = title.toLowerCase();
538 // Widths from an average glyph of 6.5px at 11px: close enough to centre.
539 const left = Math.round(label.length * 6.5 + 14);
540 const right = Math.round(value.length * 6.2 + 14);
541 const width = left + right;
542 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escape(`${label}: ${value}`)}">
543<title>${escape(`${label}: ${value}`)}</title>
544<clipPath id="r"><rect width="${width}" height="20" rx="4"/></clipPath>
545<g clip-path="url(#r)"><rect width="${left}" height="20" fill="#1e1e21"/><rect x="${left}" width="${right}" height="20" fill="${color}"/></g>
546<g font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">
547<text x="${left / 2}" y="14" fill="#ededef">${escape(label)}</text>
548<text x="${left + right / 2}" y="14" fill="#0f0f11">${escape(value)}</text>
549</g></svg>`;
550}
551
552/** The page's script: day detail on hover, tap and arrow keys; times in the reader's words. */
553export const SCRIPT = `(() => {
554 const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
Fast pages, required checks on the branch, self-hosted runners, honest incidents555 const fmt = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" });
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas556 function ago(at) {
557 const s = (Date.parse(at) - Date.now()) / 1000;
558 const a = Math.abs(s);
559 if (a < 45) return rtf.format(Math.round(s), "second");
560 if (a < 2700) return rtf.format(Math.round(s / 60), "minute");
561 if (a < 79200) return rtf.format(Math.round(s / 3600), "hour");
562 return rtf.format(Math.round(s / 86400), "day");
563 }
564 function times() {
565 for (const t of document.querySelectorAll("time[datetime]")) {
566 const at = t.getAttribute("datetime");
Fast pages, required checks on the branch, self-hosted runners, honest incidents567 if (t.hasAttribute("data-relative")) {
568 t.title = fmt.format(new Date(at));
569 t.textContent = ago(at);
570 } else t.textContent = fmt.format(new Date(at));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas571 }
572 }
573 times();
574 setInterval(times, 30000);
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.575 // The theme switch: Auto, Light or Dark, kept in this browser (theme.js applies it before paint).
576 const choices = [...document.querySelectorAll("[data-theme-choice]")];
577 function pressed() {
578 const now = document.documentElement.dataset.theme || "auto";
579 for (const b of choices) b.setAttribute("aria-pressed", String(b.dataset.themeChoice === now));
580 }
581 for (const b of choices) {
582 b.addEventListener("click", () => {
583 const theme = b.dataset.themeChoice;
584 document.documentElement.dataset.theme = theme;
585 try {
586 if (theme === "auto") localStorage.removeItem(${JSON.stringify(THEME_KEY)});
587 else localStorage.setItem(${JSON.stringify(THEME_KEY)}, theme);
588 } catch {}
589 pressed();
590 });
591 }
592 pressed();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas593 for (const bar of document.querySelectorAll(".bar")) {
594 const detail = bar.parentElement.querySelector(".detail");
595 const days = [...bar.children];
596 let at = -1;
597 function show(i) {
598 days.forEach((d, j) => d.classList.toggle("on", j === i));
599 at = i;
600 detail.textContent = i >= 0 ? days[i].dataset.detail : "";
601 }
602 bar.addEventListener("pointerover", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); });
603 bar.addEventListener("click", (e) => { const i = days.indexOf(e.target); if (i >= 0) show(i); });
604 bar.addEventListener("pointerleave", (e) => { if (e.pointerType === "mouse" && document.activeElement !== bar) show(-1); });
605 bar.addEventListener("blur", () => show(-1));
606 bar.addEventListener("keydown", (e) => {
607 const visible = days.filter((d) => d.offsetParent !== null);
608 const first = days.indexOf(visible[0]);
609 if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
610 e.preventDefault();
611 const next = at < 0 ? days.length - 1 : at + (e.key === "ArrowLeft" ? -1 : 1);
612 show(Math.min(days.length - 1, Math.max(first, next)));
613 } else if (e.key === "Home") { e.preventDefault(); show(first); }
614 else if (e.key === "End") { e.preventDefault(); show(days.length - 1); }
615 });
616 bar.addEventListener("focus", () => { if (at < 0) show(days.length - 1); });
617 }
618 // Fresh numbers without a reload, while the front page is in view and no one is typing.
619 if (document.body.hasAttribute("data-live")) {
620 setInterval(() => {
621 const busy = document.querySelector(".bar:focus") || (document.activeElement && document.activeElement.tagName === "INPUT");
622 if (document.visibilityState === "visible" && !busy) location.reload();
623 }, 120000);
624 }
625})();
626`;
627
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.628/**
629 * The light palette: g1t's light tokens (--g1t-light-* in
630 * packages/theme/tokens.css), every text colour at least 4.5:1 on the
631 * page and on a panel. The day bars take softer fills than the state
632 * words, which are text.
633 */
634const LIGHT = `--bg:#fbfbfa;--surface:#ffffff;--raised:#f1f1ef;--line:#e4e4e1;--line-strong:#d2d2ce;--fg:#18181b;--soft:#2e2e33;--muted:#5c5c66;--faint:#686871;
635--up:#0d7a52;--warn:#a84f00;--down:#c42a43;--lav:#6b52c8;--lav-hover:#5b42b8;--info:#2160cc;
636--bar-up:#3a9d77;--bar-warn:#e08a3c;--bar-down:#d9475d;color-scheme:light`;
637
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas638const STYLE = `
639@font-face{font-family:"Hanken Grotesk";font-weight:400 700;font-display:swap;src:url(/fonts/hanken-grotesk.woff2) format("woff2")}
640@font-face{font-family:"Bricolage Grotesque";font-weight:500 700;font-display:swap;src:url(/fonts/bricolage-grotesque.woff2) format("woff2")}
641@font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url(/fonts/ibm-plex-mono.woff2) format("woff2")}
642:root{--bg:#0f0f11;--surface:#161618;--raised:#1e1e21;--line:#28282c;--line-strong:#38383e;--fg:#ededef;--soft:#dcdce0;--muted:#a0a0a8;--faint:#86868e;
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.643--up:#86efc4;--warn:#ffbd8c;--down:#ff8394;--lav:#b6a8ff;--lav-hover:#c8bdff;--info:#8ec5ff;
644--bar-up:var(--up);--bar-warn:var(--warn);--bar-down:var(--down);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas645--sans:"Hanken Grotesk",ui-sans-serif,system-ui,sans-serif;--display:"Bricolage Grotesque",ui-sans-serif,system-ui,sans-serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color-scheme:dark}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.646@media (prefers-color-scheme:light){:root:not([data-theme=dark]){${LIGHT}}}
647:root[data-theme=light]{${LIGHT}}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas648*{box-sizing:border-box}
649html{-webkit-text-size-adjust:100%}
650body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}
651a{color:inherit}
652a:focus-visible,.bar:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--lav);outline-offset:3px;border-radius:4px}
653.page{max-width:760px;margin:0 auto;padding:0 16px 48px}
654.top{display:flex;align-items:center;justify-content:space-between;gap:16px;height:72px;border-bottom:1px solid var(--line)}
655.brand{display:flex;align-items:center;gap:12px;text-decoration:none}
656.brand span{font:600 20px/1 var(--display);letter-spacing:-.01em;color:var(--muted);padding-left:12px;border-left:1px solid var(--line-strong)}
657.logo{height:22px;width:auto;display:block}.logo rect{fill:var(--fg)}.logo rect.one{fill:var(--lav)}
658.out{font-size:13px;color:var(--muted);text-decoration:none}.out:hover{color:var(--fg)}
659.banner{display:flex;gap:14px;align-items:flex-start;margin-top:32px;padding:20px 22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.660.banner.up{border-color:color-mix(in srgb,var(--up) 28%,transparent);background:linear-gradient(0deg,color-mix(in srgb,var(--up) 4%,transparent),color-mix(in srgb,var(--up) 4%,transparent)),var(--surface)}
661.banner.degraded{border-color:color-mix(in srgb,var(--warn) 40%,transparent);background:linear-gradient(0deg,color-mix(in srgb,var(--warn) 6%,transparent),color-mix(in srgb,var(--warn) 6%,transparent)),var(--surface)}
662.banner.down{border-color:color-mix(in srgb,var(--down) 45%,transparent);background:linear-gradient(0deg,color-mix(in srgb,var(--down) 7%,transparent),color-mix(in srgb,var(--down) 7%,transparent)),var(--surface)}
663.banner.maintenance{border-color:color-mix(in srgb,var(--info) 40%,transparent);background:linear-gradient(0deg,color-mix(in srgb,var(--info) 6%,transparent),color-mix(in srgb,var(--info) 6%,transparent)),var(--surface)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas664.banner h1{margin:0;font:600 26px/1.2 var(--display);letter-spacing:-.02em}
665.banner p{margin:6px 0 0;color:var(--muted);font-size:14px}
666.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.667.dot.big{width:12px;height:12px;margin-top:9px;box-shadow:0 0 0 4px color-mix(in srgb,var(--up) 12%,transparent)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas668.dot.up{background:var(--up)}.dot.degraded,.dot.partial{background:var(--warn)}.dot.down{background:var(--down)}.dot.maintenance{background:var(--info)}.dot.unmonitored,.dot.unknown{background:var(--faint)}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.669.dot.big.degraded{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 14%,transparent)}.dot.big.down{box-shadow:0 0 0 4px color-mix(in srgb,var(--down) 16%,transparent)}.dot.big.maintenance{box-shadow:0 0 0 4px color-mix(in srgb,var(--info) 14%,transparent)}.dot.big.unknown{box-shadow:none}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas670.block{margin-top:40px}
671.block h2,.month h2{margin:0 0 12px;font:600 13px/1.4 var(--sans);color:var(--muted);letter-spacing:.02em}
672.block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
673.block-head a{font-size:13px;color:var(--muted);text-decoration:none}.block-head a:hover{color:var(--fg)}
674.components{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
675.component{padding:16px 18px 14px}
676.component+.component{border-top:1px solid var(--line)}
677.row{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
678.name{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 10px;min-width:0}
679.name h3{margin:0;font-size:15px;font-weight:600}
680.address{font:12px/1.4 var(--mono);color:var(--faint);overflow-wrap:anywhere}
681.state{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:500;white-space:nowrap}
682.s-up{color:var(--up)}.s-degraded,.s-partial{color:var(--warn)}.s-down{color:var(--down)}.s-maintenance{color:var(--info)}.s-unmonitored{color:var(--faint)}
683.seen{margin:2px 0 0;font-size:12px;color:var(--faint)}
684.unchecked{margin:8px 0 2px;font-size:13px;color:var(--faint)}
685.history{margin-top:12px}
686.bar{list-style:none;margin:0;padding:0;display:flex;gap:2px;height:30px}
687.bar li{flex:1 1 0;min-width:0;border-radius:2px;background:var(--line);transition:opacity .12s}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.688.bar li.up{background:var(--bar-up);opacity:.85}.bar li.degraded{background:var(--bar-warn)}.bar li.down{background:var(--bar-down)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas689.bar:hover li,.bar:focus li{opacity:.55}.bar li.on,.bar:hover li:hover{opacity:1;transform:scaleY(1.08)}
690.legend{display:flex;justify-content:space-between;margin-top:6px;font-size:12px;color:var(--faint)}
691.legend .uptime{color:var(--muted)}
692.legend .short{display:none}
693.detail{margin:4px 0 0;min-height:18px;font-size:12px;color:var(--soft)}
694.incident,.card{border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:16px 18px;margin-bottom:12px}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.695.incident.open.degraded{border-color:color-mix(in srgb,var(--warn) 40%,transparent)}.incident.open.down{border-color:color-mix(in srgb,var(--down) 45%,transparent)}.incident.maint{border-color:color-mix(in srgb,var(--info) 35%,transparent)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas696.incident header,.page-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:6px 12px}
697.incident h3{margin:0;font-size:15px;font-weight:600}
698.incident h3 a,.line-head a{text-decoration:none}.incident h3 a:hover,.line-head a:hover{text-decoration:underline;text-underline-offset:3px}
699.page-head{margin-top:20px}
700.page-head h1{margin:0;font:600 24px/1.25 var(--display);letter-spacing:-.02em;overflow-wrap:anywhere}
701.crumbs{margin:24px 0 0;font-size:13px;color:var(--faint)}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--fg)}
702.pill{font-size:12px;font-weight:500;padding:1px 8px;border-radius:999px;border:1px solid var(--line-strong);color:var(--muted);white-space:nowrap}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.703.pill.degraded{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,transparent)}.pill.down{color:var(--down);border-color:color-mix(in srgb,var(--down) 45%,transparent)}.pill.maint{color:var(--info);border-color:color-mix(in srgb,var(--info) 40%,transparent)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas704.meta{margin:4px 0 0;font-size:12px;color:var(--faint)}
705.impacts{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px}
706.affects{font-size:12px;color:var(--muted)}
707.impact{margin-left:4px;font-weight:500}.i-degraded,.i-partial_outage{color:var(--warn)}.i-major_outage{color:var(--down)}.i-operational{color:var(--up)}
708.updates{list-style:none;margin:12px 0 0;padding:0 0 0 14px;border-left:1px solid var(--line-strong)}
709.card>.updates{margin-top:0}
710.updates li+li{margin-top:12px}
711.when{margin:0;font-size:12px;color:var(--faint)}.when.latest{margin-top:8px}.when strong{color:var(--soft);font-weight:600;margin-right:4px}
712.text p,.text li{margin:4px 0 0;font-size:14px;color:var(--soft)}
713.text ul{margin:4px 0 0;padding-left:20px}
714.empty{margin:0;color:var(--muted);font-size:14px;padding:16px 18px;border:1px dashed var(--line-strong);border-radius:14px}
715.muted{color:var(--muted);font-size:14px}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.716.note{margin:14px 0 0;font-size:14px;color:var(--soft);padding:10px 14px;border:1px solid color-mix(in srgb,var(--lav) 35%,transparent);border-radius:10px;background:color-mix(in srgb,var(--lav) 6%,transparent)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas717.lines{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
718.line{padding:12px 18px}.line+.line{border-top:1px solid var(--line)}
719.line-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-weight:600;font-size:15px}
720.tag{font-size:12px;font-weight:500;color:var(--lav);text-decoration:none;white-space:nowrap}
721.last{margin:4px 0 0;font-size:13px;color:var(--muted)}
722.month{margin-top:28px}.month:has(.none){margin-top:18px}.month:has(.none) h2{margin-bottom:2px}
723.postmortem .pm-section+.pm-section{margin-top:18px}
724.pm-section h3{margin:0;font-size:15px;font-weight:600}
725form.subscribe{display:flex;gap:8px;flex-wrap:wrap}
726input[type=email]{flex:1 1 220px;min-width:0;font:inherit;font-size:14px;color:var(--fg);background:var(--bg);border:1px solid var(--line-strong);border-radius:8px;padding:9px 12px}
727input[type=email]::placeholder{color:var(--faint)}
728button{font:inherit;font-size:14px;font-weight:600;color:var(--bg);background:var(--lav);border:0;border-radius:8px;padding:9px 16px;cursor:pointer}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.729button:hover{background:var(--lav-hover)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas730.trap{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
731.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
732.feeds{margin:10px 0 0;font-size:13px;color:var(--faint)}.feeds a,.muted a,.note a{color:var(--soft)}
733.stack{display:flex;flex-direction:column;gap:10px}.stack label{font-size:13px;color:var(--muted)}
734.stack button{align-self:flex-start}.stack input[type=email]{flex:none}
735.page-head+.card,.page-head+.muted+.card{margin-top:16px}
736.month .none{margin:0;font-size:13px;color:var(--faint)}
737fieldset{border:0;padding:0;margin:6px 0 0}legend{font-size:13px;color:var(--muted);padding:0}
738fieldset .muted{margin:2px 0 8px;font-size:13px}
739.checks{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px}
740.check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--soft)!important;padding:6px 10px;border:1px solid var(--line);border-radius:8px}
741.check input{accent-color:var(--lav)}
742.message{margin-top:24px}.message h1{margin:0;font:600 22px/1.25 var(--display)}.message p{color:var(--muted);font-size:14px}
743.about p{margin:0;font-size:13px;color:var(--muted);max-width:68ch}
744.about dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0 0;font-size:13px}
745.about dt{color:var(--soft)}.about dd{margin:0;color:var(--faint)}
746.foot{display:flex;flex-wrap:wrap;gap:8px;margin-top:48px;padding-top:20px;border-top:1px solid var(--line);font-size:13px;color:var(--faint)}
747.foot a{text-decoration:none;color:var(--muted)}.foot a:hover{color:var(--fg)}
docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.748.top-end{display:flex;align-items:center;gap:16px}
749.theme{display:flex;gap:2px;padding:2px;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
750:root:not([data-theme]) .theme{display:none}
751.theme button{display:grid;place-items:center;width:26px;height:24px;padding:0;border-radius:6px;background:transparent;color:var(--faint)}
752.theme button:hover{background:var(--raised);color:var(--fg)}
753:root[data-theme=auto] .theme [data-theme-choice=auto],:root[data-theme=light] .theme [data-theme-choice=light],:root[data-theme=dark] .theme [data-theme-choice=dark]{background:var(--raised);color:var(--fg)}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas754@media (max-width:640px){
755.bar li:nth-child(-n+${HISTORY_DAYS - 30}){display:none}
756.bar{gap:3px}
757.legend .long{display:none}.legend .short{display:inline}
758.banner{padding:16px}.banner h1{font-size:22px}
759.component,.incident,.card,.line{padding:14px}
760.about dl{grid-template-columns:1fr;gap:2px}.about dd{margin-bottom:8px}
761}
762@media (prefers-reduced-motion:reduce){.bar li{transition:none}}
763`;