Skip to content
763 linesCodeBlameRaw
1/**
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
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
9 * 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:
11 * 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";
23import { timeIn, validZone } from "./time.ts";
24
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;
37 /** The reader's time zone (time.ts `readZone`); UTC when absent. */
38 zone?: string;
39};
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
54/**
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);
68}
69
70/** A time in the reader's zone; the UTC instant in `datetime` and, in words, on hover. */
71function time(at: string, relative = false): string {
72 return `<time datetime="${escape(at)}" title="${escape(timeIn(at, "UTC"))}"${relative ? " data-relative" : ""}>${escape(timeLabel(at))}</time>`;
73}
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
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
159function 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)}">
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>
296<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>
316<div class="top-end">
317${THEME_SWITCH}
318<a class="out" href="${escape(site)}/">${escape(siteHost)} <span aria-hidden="true">→</span></a>
319</div>
320</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 {
347 zoned(options);
348 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>
386<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>
387<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 {
416 zoned(options);
417 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 {
444 zoned(options);
445 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 {
472 zoned(options);
473 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" });
555 const fmt = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" });
556 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");
567 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));
571 }
572 }
573 times();
574 setInterval(times, 30000);
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();
593 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
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
638const 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;
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);
645--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}
646@media (prefers-color-scheme:light){:root:not([data-theme=dark]){${LIGHT}}}
647:root[data-theme=light]{${LIGHT}}
648*{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)}
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)}
664.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}
667.dot.big{width:12px;height:12px;margin-top:9px;box-shadow:0 0 0 4px color-mix(in srgb,var(--up) 12%,transparent)}
668.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)}
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}
670.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}
688.bar li.up{background:var(--bar-up);opacity:.85}.bar li.degraded{background:var(--bar-warn)}.bar li.down{background:var(--bar-down)}
689.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}
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)}
696.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}
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)}
704.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}
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)}
717.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}
729button:hover{background:var(--lav-hover)}
730.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)}
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)}
754@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`;