Skip to content

Commit

docs.g1t.sh and status.g1t.sh follow your system's light or dark setting, and each has an Auto, Light and Dark switch that this browser remembers: the docs' in the header (at the foot of the menu on a phone), with code, search, the API reference and its explorer in both themes; the status page's in its header, chosen before anything is drawn; the workspaces guide says where.

syntaqxcommitted Parent4dcb1fbBrowse files
11 files+346−450/11 viewed
+13−3
2929 // Each tab under the header shows its own part of the sidebar.
3030 routeMiddleware: './src/route-data.ts',
3131 expressiveCode: {
32− themes: ['github-dark-default'],
32+ // Dark first, as the base; the light one where the page is light.
33+ // Starlight switches between them by <html data-theme>.
34+ themes: ['github-dark-default', 'github-light-default'],
35+ useDarkModeMediaQuery: false,
3336 // Plain panels with a copy button, without window chrome.
3437 defaultProps: { frame: 'none' },
3538 styleOverrides: {
4952 },
5053 logo: { src: '@g1t/theme/mark.svg', alt: '' },
5154 favicon: '/favicon.svg',
52− customCss: ['@g1t/theme/fonts.css', '@g1t/theme/tokens.css', './src/styles/g1t.css'],
55+ customCss: [
56+ '@g1t/theme/fonts.css',
57+ '@g1t/theme/tokens.css',
58+ './src/styles/light.css',
59+ './src/styles/g1t.css',
60+ ],
5361 editLink: {
5462 baseUrl: 'https://g1t.sh/flagon-io/g1t/blob/main/apps/docs/',
5563 },
6068 { tag: 'link', attrs: { rel: 'icon', type: 'image/png', sizes: '512x512', href: '/icon-512.png' } },
6169 { tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' } },
6270 { tag: 'link', attrs: { rel: 'manifest', href: '/site.webmanifest' } },
63− { tag: 'meta', attrs: { name: 'theme-color', content: '#0f0f11' } },
71+ // The browser's own bars, in the page's colour for the system's setting.
72+ { tag: 'meta', attrs: { name: 'theme-color', content: '#0f0f11', media: '(prefers-color-scheme: dark)' } },
73+ { tag: 'meta', attrs: { name: 'theme-color', content: '#fbfbfa', media: '(prefers-color-scheme: light)' } },
6474 ],
6575 sidebar: [
6676 {
+35−1
77 // and when it does not. Here the row is three columns, the outer two always
88 // the same width, so the search sits at the header's centre on every page.
99 import Search from '@astrojs/starlight/components/Search.astro';
10+// Auto, Light or Dark, kept in this browser; Auto follows the system.
11+// On tablets and phones it is at the foot of the menu instead.
12+import ThemeSelect from '@astrojs/starlight/components/ThemeSelect.astro';
1013
1114 import { TABS, tabFor } from '../tabs';
1215 import SiteTitle from './SiteTitle.astro';
1922 <div class="g1t-header-top">
2023 <div class="g1t-header-title"><SiteTitle /></div>
2124 <div class="g1t-header-search print:hidden"><Search /></div>
22− <div class="g1t-header-links print:hidden"><SocialIcons /></div>
25+ <div class="g1t-header-links print:hidden">
26+ <SocialIcons />
27+ <div class="g1t-header-theme"><ThemeSelect /></div>
28+ </div>
2329 </div>
2430 <nav class="g1t-tabs" aria-label="Sections">
2531 {
7076 }
7177 .g1t-header-links {
7278 display: flex;
79+ align-items: center;
7380 justify-content: flex-end;
81+ gap: 0.75rem;
7482 min-width: 0;
7583 }
84+ .g1t-header-theme {
85+ display: flex;
86+ align-items: center;
87+ padding-inline-start: 0.75rem;
88+ border-inline-start: 1px solid var(--g1t-line);
89+ height: 1.25rem;
90+ }
91+ /* Desktop: the links and the theme select together are wider. On a
92+ narrow desktop the select shows only its icon (styles/g1t.css), so the
93+ search keeps its room. */
94+ @media (min-width: 50rem) {
95+ .g1t-header-top {
96+ --g1t-header-side: 17.5rem;
97+ }
98+ }
99+ @media (min-width: 64rem) {
100+ .g1t-header-top {
101+ --g1t-header-side: 21rem;
102+ }
103+ }
104+ /* Tablets and phones: the theme select is at the foot of the menu. */
105+ @media (max-width: 49.99rem) {
106+ .g1t-header-theme {
107+ display: none;
108+ }
109+ }
76110 /* Tablets: the sidebar is a menu, and its button (fixed in place by
77111 Starlight) takes the row's end. The right column makes room for it
78112 inside itself, so the two columns stay equal and the search centred. */
+1−1
168168 background: var(--g1t-raised);
169169 box-shadow:
170170 inset 0 0 0 1px var(--g1t-line-strong),
171− 0 12px 32px rgb(0 0 0 / 0.45);
171+ var(--g1t-docs-menu-shadow);
172172 }
173173 /* Its own spacing: the page's prose rules stop at not-content, and
174174 nothing inside keeps a margin of its own. */
+1−1
4545 font-weight: 500;
4646 }
4747 nav.g1t-nav .g1t-nav-cta:hover {
48− background: #fff;
48+ background: var(--g1t-docs-fg-hover);
4949 color: var(--g1t-bg);
5050 }
5151 nav.g1t-nav .g1t-nav-icon {
+5−0
589589 Product screenshots on g1t.sh's home page, and the g1t mark on agents'
590590 avatars, stay dark in either theme.
591591
592+These docs and [status.g1t.sh](https://status.g1t.sh/) also follow your
593+system's setting, each with its own switch for this browser: here, the
594+**Auto**, **Light** and **Dark** menu at the end of the header (at the foot
595+of the menu on a phone); on the status page, the three icons at the top.
596+
592597 ### Apps
593598
594599 Every other app in the workspace is a page you can pin to your dock:
+36−3
44 // layout. The reference itself is the Starlight pages under /reference/api/.
55 import '@g1t/theme/fonts.css';
66 import '@g1t/theme/tokens.css';
7+import '../../styles/light.css';
78 import sans from '@g1t/theme/fonts/hanken-grotesk-latin.woff2?url';
89
910 // The wordmark: G1T in pixel capitals, the 1 in lavender, as in
1516 rows.flatMap((row, y) => [...row].flatMap((c, x) => (c === '#' ? [{ x: dx + x, y, one }] : [])));
1617 const word = [...pixels(G, 0, false), ...pixels(ONE, 6, true), ...pixels(T, 10, false)];
1718
19+// Light or dark as the rest of the docs: the head's script below sets
20+// forceDarkModeState from the reader's choice before the explorer loads.
1821 const configuration = {
1922 theme: 'none',
2023 darkMode: true,
4144 <link rel="manifest" href="/site.webmanifest" />
4245 <link rel="canonical" href="https://docs.g1t.sh/api/reference/" />
4346 <link rel="preload" href={sans} as="font" type="font/woff2" crossorigin />
47+ <meta name="theme-color" content="#0f0f11" media="(prefers-color-scheme: dark)" />
48+ <meta name="theme-color" content="#fbfbfa" media="(prefers-color-scheme: light)" />
49+ {/* The theme chosen in the docs' theme select (kept in this browser by
50+ Starlight), or the system's on Auto; set before the first paint. */}
51+ <script is:inline>
52+ (() => {
53+ let stored = '';
54+ try {
55+ stored = localStorage.getItem('starlight-theme') || '';
56+ } catch {}
57+ const theme =
58+ stored === 'light' || stored === 'dark'
59+ ? stored
60+ : matchMedia('(prefers-color-scheme: light)').matches
61+ ? 'light'
62+ : 'dark';
63+ document.documentElement.dataset.theme = theme;
64+ window.g1tTheme = theme;
65+ })();
66+ </script>
4467 </head>
4568 <body>
4669 <header class="bar">
7194 id="api-reference"
7295 data-url="https://api.g1t.sh/openapi.json"
7396 data-configuration={JSON.stringify(configuration)}></script>
97+ <script is:inline>
98+ (() => {
99+ const el = document.getElementById('api-reference');
100+ const config = JSON.parse(el.dataset.configuration);
101+ config.darkMode = window.g1tTheme !== 'light';
102+ config.forceDarkModeState = window.g1tTheme === 'light' ? 'light' : 'dark';
103+ el.dataset.configuration = JSON.stringify(config);
104+ })();
105+ </script>
74106 <script is:inline src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
75107 </body>
76108 </html>
88120 --scalar-font: var(--g1t-font-sans);
89121 --scalar-font-code: var(--g1t-font-mono);
90122 }
91− .dark-mode {
123+ .dark-mode,
124+ .light-mode {
92125 --scalar-background-1: var(--g1t-bg);
93126 --scalar-background-2: var(--g1t-surface);
94127 --scalar-background-3: var(--g1t-raised);
96129 --scalar-color-1: var(--g1t-fg);
97130 --scalar-color-2: var(--g1t-muted);
98131 --scalar-color-3: var(--g1t-faint);
99− --scalar-color-accent: #cfc6ff;
132+ --scalar-color-accent: var(--g1t-docs-link);
100133 --scalar-background-accent: color-mix(in srgb, var(--g1t-accent) 12%, transparent);
101134 --scalar-button-1: var(--g1t-fg);
102135 --scalar-button-1-color: var(--g1t-bg);
103− --scalar-button-1-hover: #ffffff;
136+ --scalar-button-1-hover: var(--g1t-docs-fg-hover);
104137 --scalar-color-green: var(--g1t-success);
105138 --scalar-color-blue: var(--g1t-info);
106139 --scalar-color-red: var(--g1t-danger);
+44−20
11 /*
2− * g1t's docs: Starlight in g1t's own design. A very dark gray base,
3− * lavender as the accent for links and the current page, quiet
4− * typography, and one theme.
2+ * g1t's docs: Starlight in g1t's own design. A very dark gray base (or a
3+ * near-white one, in light: styles/light.css), lavender as the accent for
4+ * links and the current page, and quiet typography. Starlight's colours
5+ * are mapped onto the g1t tokens once, here, for both themes.
56 */
6−:root,
7−:root[data-theme='light'] {
7+:root {
88 --sl-font: var(--g1t-font-sans);
99 --sl-font-mono: var(--g1t-font-mono);
1010
1111 /* Lavender is the docs' accent: links, the current page, highlights. */
1212 --sl-color-accent-low: color-mix(in srgb, var(--g1t-accent) 14%, var(--g1t-bg));
1313 --sl-color-accent: var(--g1t-accent);
14− --sl-color-accent-high: #d9d1ff;
14+ --sl-color-accent-high: var(--g1t-docs-accent-high);
1515
1616 --sl-color-white: var(--g1t-fg);
1717 --sl-color-gray-1: var(--g1t-fg-soft);
3030 --sl-color-hairline-light: var(--g1t-line);
3131 --sl-color-hairline-shade: var(--g1t-line);
3232 --sl-color-text: var(--g1t-fg-soft);
33− --sl-color-text-accent: #cfc6ff;
33+ --sl-color-text-accent: var(--g1t-docs-link);
3434 --sl-color-text-invert: var(--g1t-bg);
3535 --sl-color-bg-inline-code: var(--g1t-raised);
3636
4343 --sl-sidebar-width: 17rem;
4444 /* Big enough to tap. */
4545 --sl-menu-button-size: 2.5rem;
46+}
4647
47− color-scheme: dark;
48+/* Starlight's theme select (Auto, Light, Dark): in the header's links, and
49+ at the foot of the phone menu. Drawn like the links beside it. */
50+starlight-theme-select label {
51+ color: var(--g1t-muted);
52+ font-size: 0.85rem;
53+}
54+starlight-theme-select label:hover {
55+ color: var(--g1t-fg);
56+}
57+starlight-theme-select select {
58+ border-radius: 0.4rem;
4859 }
49−
50−/* There is one theme, so the switcher has nothing to switch. */
51−starlight-theme-select {
52− display: none;
60+starlight-theme-select option {
61+ background: var(--g1t-surface);
62+ color: var(--g1t-fg);
5363 }
54−/* Starlight's divider between the links and the theme and language
55− pickers; with neither shown, it is a line before nothing. */
56−.social-icons::after {
57− display: none;
64+/* A narrow desktop: in the header, only the select's icon and caret; the
65+ choice is still its accessible name and in the list it opens. */
66+@media (min-width: 50rem) and (max-width: 63.99rem) {
67+ .g1t-header-theme starlight-theme-select select {
68+ width: 3.625rem;
69+ color: transparent;
70+ }
5871 }
5972
6073 /* --- Header --------------------------------------------------------------- */
134147 color: var(--g1t-fg);
135148 }
136149
150+/* Search, in light: the dialog is a white panel, so its results sit on a
151+ step darker rather than on a white just off the panel's. */
152+:root[data-theme='light'] #starlight__search {
153+ --pagefind-ui-background: var(--g1t-surface);
154+}
155+:root[data-theme='light']
156+ #starlight__search
157+ :is(.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)), .pagefind-ui__result-nested):not(:hover) {
158+ background-color: var(--g1t-raised);
159+}
160+
137161 /* --- Sidebar -------------------------------------------------------------- */
138162
139163 .sidebar-content {
230254 color: var(--g1t-fg-soft);
231255 }
232256 .sl-markdown-content a:not(.card, .sl-link-button, .sl-link-card) {
233− color: #cfc6ff;
257+ color: var(--g1t-docs-link);
234258 text-decoration-color: color-mix(in srgb, var(--g1t-accent) 45%, transparent);
235259 text-underline-offset: 0.2em;
236260 }
338362 color: var(--g1t-fg);
339363 }
340364 .right-sidebar-panel a[aria-current='true'] {
341− color: #cfc6ff;
365+ color: var(--g1t-docs-link);
342366 }
343367
344368 /* --- Cards and buttons ---------------------------------------------------- */
365389 color: var(--g1t-bg);
366390 }
367391 .sl-link-button.primary:hover {
368− background: #fff;
392+ background: var(--g1t-docs-fg-hover);
369393 }
370394 .sl-link-button.minimal {
371395 color: var(--g1t-fg-soft);
472496 border: 0 !important;
473497 border-radius: 0.3rem;
474498 padding: 0.05rem 0.4rem;
475− background: color-mix(in srgb, var(--method) 14%, transparent) !important;
499+ background: color-mix(in srgb, var(--method) var(--g1t-docs-tag-tint), transparent) !important;
476500 color: var(--method) !important;
477501 font-family: var(--g1t-font-mono);
478502 font-size: 0.7rem;
+64−0
1+/*
2+ * The docs' two themes. Dark is g1t's own very dark gray; light swaps the
3+ * shared --g1t-* tokens for their light values (--g1t-light-* in
4+ * packages/theme/tokens.css, the same palette as the app's Light
5+ * appearance), so every rule that reads a token follows without a light
6+ * rule of its own.
7+ *
8+ * Starlight sets data-theme on <html> before the first paint: the reader's
9+ * choice in its theme select (Auto, Light or Dark, kept in this browser),
10+ * and on Auto their system's setting. The API explorer
11+ * (src/pages/api/reference.astro) sets it the same way.
12+ *
13+ * The few colours the docs use that have no shared token are here as
14+ * --g1t-docs-*, dark first.
15+ */
16+:root {
17+ /* Link text: a paler lavender than the accent on dark. */
18+ --g1t-docs-link: #cfc6ff;
19+ /* Text on a faint accent tint. */
20+ --g1t-docs-accent-high: #d9d1ff;
21+ /* A primary button's hover: brighter than fg on dark, softer on light. */
22+ --g1t-docs-fg-hover: #ffffff;
23+ /* The shadow under a menu that floats over the page. */
24+ --g1t-docs-menu-shadow: 0 12px 32px rgb(0 0 0 / 0.45);
25+ /* How much of an API method's colour tints its tag. */
26+ --g1t-docs-tag-tint: 14%;
27+ color-scheme: dark;
28+}
29+
30+:root[data-theme='light'] {
31+ --g1t-bg: var(--g1t-light-bg);
32+ --g1t-surface: var(--g1t-light-surface);
33+ --g1t-raised: var(--g1t-light-raised);
34+ --g1t-line: var(--g1t-light-line);
35+ --g1t-line-strong: var(--g1t-light-line-strong);
36+
37+ --g1t-fg: var(--g1t-light-fg);
38+ --g1t-fg-soft: var(--g1t-light-fg-soft);
39+ --g1t-muted: var(--g1t-light-muted);
40+ --g1t-faint: var(--g1t-light-faint);
41+
42+ --g1t-accent: var(--g1t-light-accent);
43+ --g1t-accent-hover: var(--g1t-light-accent-hover);
44+ --g1t-accent-dim: var(--g1t-light-accent-dim);
45+ --g1t-accent-low: var(--g1t-light-accent-low);
46+
47+ --g1t-success: var(--g1t-light-success);
48+ --g1t-success-dim: var(--g1t-light-success-dim);
49+ --g1t-success-low: var(--g1t-light-success-low);
50+
51+ --g1t-info: var(--g1t-light-info);
52+ --g1t-merged: var(--g1t-light-merged);
53+ --g1t-warn: var(--g1t-light-warn);
54+ --g1t-danger: var(--g1t-light-danger);
55+
56+ /* The accent itself as link text: 5.5:1 on the page. */
57+ --g1t-docs-link: var(--g1t-light-accent);
58+ --g1t-docs-accent-high: #4b36a6;
59+ --g1t-docs-fg-hover: #3a3a41;
60+ --g1t-docs-menu-shadow: 0 12px 32px rgb(24 24 27 / 0.12);
61+ /* Lighter, so each method's text keeps 4.5:1 on its tag. */
62+ --g1t-docs-tag-tint: 9%;
63+ color-scheme: light;
64+}
+4−0
1515 * GET / the page
1616 * GET /status.json the same as JSON (snake_case, CORS open)
1717 * GET /badge.svg a small badge
18+ * GET /theme.js, /status.js the page's scripts: its theme before paint; hover detail and the theme switch
1819 * GET /incidents/<id> an incident's updates and postmortem
1920 * GET /maintenance/<id> a maintenance window's updates
2021 * GET /history the last 12 months, by month
105106 import {
106107 FAVICON,
107108 SCRIPT,
109+ THEME_SCRIPT,
108110 type PageOptions,
109111 renderBadge,
110112 renderHistory,
663665 case "/subscribe/confirm":
664666 case "/unsubscribe":
665667 return (await subscriptions(request, env, ctx, url, options))!;
668+ case "/theme.js":
669+ return new Response(THEME_SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": fresh(3600), ...COMMON } });
666670 case "/status.js":
667671 return new Response(SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": fresh(3600), ...COMMON } });
668672 case "/favicon.svg":
+49−1
55
66 import { components } from "./components.ts";
77 import { type DayRow, buildPage } from "./model.ts";
8−import { byMonth, dayDetail, escape, prose, renderBadge, renderHistory, renderIncident, renderMaintenance, renderMessage, renderPage, renderSubscribe } from "./render.ts";
8+import {
9+ THEME_KEY,
10+ THEME_SCRIPT,
11+ byMonth,
12+ dayDetail,
13+ escape,
14+ prose,
15+ renderBadge,
16+ renderHistory,
17+ renderIncident,
18+ renderMaintenance,
19+ renderMessage,
20+ renderPage,
21+ renderSubscribe,
22+} from "./render.ts";
923
1024 const NOW = new Date("2026-10-05T12:00:00Z");
1125 const OPTIONS = { siteUrl: "https://g1t.sh", supportUrl: "https://g1t.sh/support", ogImage: "", selfUrl: "https://status.g1t.sh/", now: NOW, email: true };
89103 assert.ok(html.includes('href="/incidents/abc"'), "the title links to its page");
90104 });
91105
106+test("every page follows the system's light or dark setting, with a switch kept in this browser", () => {
107+ for (const html of [
108+ renderPage(page({ site: "up" }), OPTIONS),
109+ renderHistory([], [], OPTIONS),
110+ renderMessage(OPTIONS, { title: "Not found", text: "Nothing here." }),
111+ ]) {
112+ const head = html.slice(0, html.indexOf("</head>"));
113+ // The choice is applied by a script that blocks in the head, before anything is drawn.
114+ assert.match(head, /<script src="\/theme\.js"><\/script>/);
115+ assert.match(head, /<meta name="color-scheme" content="dark light">/);
116+ assert.match(head, /theme-color" content="#fbfbfa" media="\(prefers-color-scheme: light\)"/);
117+ // Dark is the base; light on the system's say-so unless Dark was chosen, or when Light was.
118+ assert.match(head, /:root\{--bg:#0f0f11;/);
119+ assert.match(head, /@media \(prefers-color-scheme:light\)\{:root:not\(\[data-theme=dark\]\)\{--bg:#fbfbfa;/);
120+ assert.match(head, /:root\[data-theme=light\]\{--bg:#fbfbfa;/);
121+ for (const choice of ["auto", "light", "dark"]) assert.ok(html.includes(`data-theme-choice="${choice}"`), choice);
122+ }
123+ assert.ok(THEME_SCRIPT.includes(JSON.stringify(THEME_KEY)));
124+ assert.ok(!/<script>/.test(renderPage(page({ site: "up" }), OPTIONS)), "no inline script: the page's policy allows only its own files");
125+});
126+
127+test("the theme script picks the stored choice, else auto", () => {
128+ for (const [stored, expected] of [["light", "light"], ["dark", "dark"], [null, "auto"], ["purple", "auto"]] as const) {
129+ const root = { dataset: {} as Record<string, string> };
130+ const run = new Function("localStorage", "document", THEME_SCRIPT);
131+ run({ getItem: (key: string) => (key === THEME_KEY ? stored : null) }, { documentElement: root });
132+ assert.equal(root.dataset.theme, expected, String(stored));
133+ }
134+ // A browser that refuses storage still gets a page.
135+ const root = { dataset: {} as Record<string, string> };
136+ new Function("localStorage", "document", THEME_SCRIPT)({ getItem: () => { throw new Error("denied"); } }, { documentElement: root });
137+ assert.equal(root.dataset.theme, "auto");
138+});
139+
92140 test("a stale page says the checks stopped", () => {
93141 const model = page({ api: "up" });
94142 const html = renderPage({ ...model, stale: true }, OPTIONS);
+94−15
22 * The pages at status.g1t.sh, drawn on the server as plain HTML: no
33 * framework, a small stylesheet inline, and a few lines of script (served
44 * 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
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
69 * the reader's zone as Cloudflare places them (PageOptions `zone`), with
710 * the zone's abbreviation; `datetime` attributes and feeds stay in UTC. Every page works without it:
811 * subscribing, confirming and leaving are plain forms. No Workers
119122 (row, y) => [...row].flatMap((c, x) => (c === "#" ? [`<rect x="${x * 2 + 3}" y="${y * 2 + 1}" width="2" height="2"/>`] : [])),
120123 ).join("")}</svg>`;
121124
125+/** Where this browser keeps the reader's theme: "light", "dark", or nothing for Auto. */
126+export 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+ */
134+export 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+
141+const 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+
144+const 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. */
155+const 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+
122159 function bar(component: StatusComponent, days: DayBar[]): string {
123160 if (component.check_state === "unmonitored" && component.uptime_90d == null) {
124161 return `<p class="unchecked">${escape(component.checks)}</p>`;
252289 <meta name="viewport" content="width=device-width, initial-scale=1">
253290 <title>${escape(page.title)}</title>
254291 <meta name="description" content="${escape(page.description)}">
255−<meta name="theme-color" content="#0f0f11">
256−<meta name="color-scheme" content="dark">
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>
257296 <link rel="icon" href="/favicon.svg" type="image/svg+xml">
258297 <link rel="canonical" href="${escape(options.selfUrl)}">
259298 <link rel="alternate" type="application/json" href="/status.json">
274313 <div class="page">
275314 <header class="top">
276315 <a class="brand" href="/" aria-label="g1t status">${LOGO}<span>Status</span></a>
316+<div class="top-end">
317+${THEME_SWITCH}
277318 <a class="out" href="${escape(site)}/">${escape(siteHost)} <span aria-hidden="true">→</span></a>
319+</div>
278320 </header>
279321 <main>
280322 ${page.body}
530572 }
531573 times();
532574 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();
533593 for (const bar of document.querySelectorAll(".bar")) {
534594 const detail = bar.parentElement.querySelector(".detail");
535595 const days = [...bar.children];
565625 })();
566626 `;
567627
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+ */
634+const 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+
568638 const STYLE = `
569639 @font-face{font-family:"Hanken Grotesk";font-weight:400 700;font-display:swap;src:url(/fonts/hanken-grotesk.woff2) format("woff2")}
570640 @font-face{font-family:"Bricolage Grotesque";font-weight:500 700;font-display:swap;src:url(/fonts/bricolage-grotesque.woff2) format("woff2")}
571641 @font-face{font-family:"IBM Plex Mono";font-weight:400;font-display:swap;src:url(/fonts/ibm-plex-mono.woff2) format("woff2")}
572642 :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;
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);
574645 --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}}
575648 *{box-sizing:border-box}
576649 html{-webkit-text-size-adjust:100%}
577650 body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}
584657 .logo{height:22px;width:auto;display:block}.logo rect{fill:var(--fg)}.logo rect.one{fill:var(--lav)}
585658 .out{font-size:13px;color:var(--muted);text-decoration:none}.out:hover{color:var(--fg)}
586659 .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)}
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)}
591664 .banner h1{margin:0;font:600 26px/1.2 var(--display);letter-spacing:-.02em}
592665 .banner p{margin:6px 0 0;color:var(--muted);font-size:14px}
593666 .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)}
667+.dot.big{width:12px;height:12px;margin-top:9px;box-shadow:0 0 0 4px color-mix(in srgb,var(--up) 12%,transparent)}
595668 .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}
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}
597670 .block{margin-top:40px}
598671 .block h2,.month h2{margin:0 0 12px;font:600 13px/1.4 var(--sans);color:var(--muted);letter-spacing:.02em}
599672 .block-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
612685 .history{margin-top:12px}
613686 .bar{list-style:none;margin:0;padding:0;display:flex;gap:2px;height:30px}
614687 .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)}
688+.bar li.up{background:var(--bar-up);opacity:.85}.bar li.degraded{background:var(--bar-warn)}.bar li.down{background:var(--bar-down)}
616689 .bar:hover li,.bar:focus li{opacity:.55}.bar li.on,.bar:hover li:hover{opacity:1;transform:scaleY(1.08)}
617690 .legend{display:flex;justify-content:space-between;margin-top:6px;font-size:12px;color:var(--faint)}
618691 .legend .uptime{color:var(--muted)}
619692 .legend .short{display:none}
620693 .detail{margin:4px 0 0;min-height:18px;font-size:12px;color:var(--soft)}
621694 .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)}
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)}
623696 .incident header,.page-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:6px 12px}
624697 .incident h3{margin:0;font-size:15px;font-weight:600}
625698 .incident h3 a,.line-head a{text-decoration:none}.incident h3 a:hover,.line-head a:hover{text-decoration:underline;text-underline-offset:3px}
627700 .page-head h1{margin:0;font:600 24px/1.25 var(--display);letter-spacing:-.02em;overflow-wrap:anywhere}
628701 .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)}
629702 .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)}
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)}
631704 .meta{margin:4px 0 0;font-size:12px;color:var(--faint)}
632705 .impacts{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:6px}
633706 .affects{font-size:12px;color:var(--muted)}
640713 .text ul{margin:4px 0 0;padding-left:20px}
641714 .empty{margin:0;color:var(--muted);font-size:14px;padding:16px 18px;border:1px dashed var(--line-strong);border-radius:14px}
642715 .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)}
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)}
644717 .lines{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:14px;background:var(--surface)}
645718 .line{padding:12px 18px}.line+.line{border-top:1px solid var(--line)}
646719 .line-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-weight:600;font-size:15px}
653726 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}
654727 input[type=email]::placeholder{color:var(--faint)}
655728 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}
729+button:hover{background:var(--lav-hover)}
657730 .trap{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
658731 .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
659732 .feeds{margin:10px 0 0;font-size:13px;color:var(--faint)}.feeds a,.muted a,.note a{color:var(--soft)}
672745 .about dt{color:var(--soft)}.about dd{margin:0;color:var(--faint)}
673746 .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)}
674747 .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)}
675754 @media (max-width:640px){
676755 .bar li:nth-child(-n+${HISTORY_DAYS - 30}){display:none}
677756 .bar{gap:3px}