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