diff --git a/index.html b/index.html index 0d4c87c..3a2dbd5 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,27 @@ Status Page + + diff --git a/public/dark.css b/public/dark.css new file mode 100644 index 0000000..6a5e4d4 --- /dev/null +++ b/public/dark.css @@ -0,0 +1,49 @@ +:root:root { + color-scheme: dark; + + --bg: #0f1210; + --card: #171b17; + --surface: #1f241f; + --surface-muted: #2b312b; + + --status-up-bg: #12281a; + --status-degraded-bg: #322a05; + --status-down-bg: #232323; + --status-unknown-bg: #191d19; + --down-chip-bg: #2a2a2a; + --down-chip-ink: #eee; + --down-chip-border: #555; + --dark-surface: #0a0c0a; + --code-bg: #0d100d; + --code-ink: #d6f0d8; + --code-border: #222722; + + --ink: #e8ece7; + --ink-invert: #f2f2f2; + --muted: #9aa09a; + --muted-2: #8a918a; + --muted-3: #767d76; + --muted-4: #b2b8b2; + --muted-5: #8b928b; + --muted-dot: #4a514a; + --white: #fff; + + --green: #2fbf5d; + --green-glow: rgba(47, 191, 93, 0.5); + --yellow: #e5b400; + --red: #ff6166; + --ok-soft: #12351f; + --ok-ink: #74e0a0; + --fail-soft: #3a1618; + --fail-ink: #ff9a9d; + --err: #ff6b70; + + --border-ink: #3a403a; + --border-muted: #3a403a; + --shadow-color: #000; + --overlay: rgba(0, 0, 0, 0.6); + --sheen: rgba(255, 255, 255, 0.07); + --sheen-strong: rgba(255, 255, 255, 0.12); + + --incidents-ink: #e8e8e8; +} diff --git a/src/app.tsx b/src/app.tsx index 1f1f8b0..44265d2 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -4,6 +4,7 @@ import { formatBytes, groupIcon, hashColor } from './format' import { Grid } from './components/Grid' import { AggregatePlot } from './components/plots' import { StatusModal } from './components/StatusModal' +import { ThemeButton } from './components/ui' export function App() { const [data, setData] = useState(null) @@ -82,6 +83,7 @@ export function App() { down unknown + {data && ( diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 4d4d162..a0efb75 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'preact/hooks' +import { useEffect, useRef, useState } from 'preact/hooks' import type { ChipState, Status } from '../types' export function Dot({ status }: { status: Status }) { @@ -56,3 +56,68 @@ export function CheckState({ state }: { state: ChipState }) { ) } + +type Theme = 'system' | 'light' | 'dark' + +const THEMES: Theme[] = ['system', 'light', 'dark'] +const THEME_ICONS: Record = { + system: 'mdi:theme-light-dark', + light: 'mdi:weather-sunny', + dark: 'mdi:weather-night', +} +const STORAGE_KEY = 'statuspage-theme' + +function applyTheme(theme: Theme) { + const link = document.getElementById('theme-dark') as HTMLLinkElement | null + if (!link) return + link.media = + theme === 'dark' + ? 'all' + : theme === 'light' + ? 'not all' + : '(prefers-color-scheme: dark)' +} + +function readTheme(): Theme { + try { + const v = localStorage.getItem(STORAGE_KEY) + if (v === 'light' || v === 'dark' || v === 'system') return v + } catch { + /* ignore */ + } + return 'system' +} + +export function ThemeButton() { + const [theme, setTheme] = useState(() => { + const t = readTheme() + applyTheme(t) + return t + }) + + useEffect(() => { + applyTheme(theme) + try { + localStorage.setItem(STORAGE_KEY, theme) + } catch { + /* ignore */ + } + }, [theme]) + + const cycle = () => { + const i = THEMES.indexOf(theme) + setTheme(THEMES[(i + 1) % THEMES.length] ?? 'system') + } + + return ( + + ) +} diff --git a/src/styles.css b/src/styles.css index 02f61d1..0c6ef4f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,13 +1,58 @@ :root { + color-scheme: light; + + /* accents */ + --green: #1aad4b; + --green-glow: rgba(26, 173, 75, 0.7); + --yellow: #e5b400; + --red: #e5484d; + --ok-soft: #c7ecd3; + --ok-ink: #0a5c22; + --fail-soft: #ffd6d6; + --fail-ink: #9c0000; + --err: #a00; + + /* surfaces */ --bg: #f2f2f2; --card: #fafafa; + --surface: #fff; + --surface-muted: #e5e5e5; + --status-up-bg: #d9f2e0; + --status-degraded-bg: #fff3b8; + --status-down-bg: #2b2b2b; + --status-unknown-bg: #e4e4e4; + --down-chip-bg: #2a2a2a; + --down-chip-ink: #eee; + --down-chip-border: #555; + --dark-surface: #111; + --code-bg: #1a1d1a; + --code-ink: #e9f7ea; + --code-border: #2a2e2a; + + /* text */ --ink: #111; - --border: 2px solid #111; - --shadow: 0.35rem 0.35rem 0 #888; - --shadow-lg: 0.55rem 0.55rem 0 #888; + --ink-invert: #f2f2f2; + --muted: #777; + --muted-2: #888; + --muted-3: #999; + --muted-4: #555; + --muted-5: #bbb; + --muted-dot: #ccc; + --white: #fff; + --on-accent: #111; + + /* lines & shadows */ + --border-ink: #111; + --border: 2px solid var(--border-ink); + --border-muted: #bbb; + --shadow-color: #888; + --shadow: 0.35rem 0.35rem 0 var(--shadow-color); + --shadow-lg: 0.55rem 0.55rem 0 var(--shadow-color); + --overlay: rgba(0, 0, 0, 0.35); + --sheen: rgba(255, 255, 255, 0.75); + --sheen-strong: rgba(255, 255, 255, 0.9); + --radius: 0.5rem; - --green: #1aad4b; - --yellow: #e5b400; --mono: "JetBrains Mono", ui-monospace, "Cascadia Code", Menlo, monospace; } @@ -68,7 +113,7 @@ body { } .conn-err { - color: #a00; + color: var(--err); font-weight: 700; } @@ -103,6 +148,37 @@ body { border-bottom-right-radius: var(--radius); } +.theme-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: var(--border); + border-radius: var(--radius); + background: var(--card); + color: var(--ink); + cursor: pointer; + box-shadow: 0.1875rem 0.1875rem 0 var(--shadow-color); + transition: transform 0.05s ease, box-shadow 0.05s ease; +} + +.theme-btn:hover { + transform: translate(-0.0625rem, -0.0625rem); + box-shadow: 0.25rem 0.25rem 0 var(--shadow-color); +} + +.theme-btn:active { + transform: translate(0.0625rem, 0.0625rem); + box-shadow: 0.0625rem 0.0625rem 0 var(--shadow-color); +} + +.theme-btn iconify-icon { + display: block; +} + .group { margin-bottom: 2rem; } @@ -120,7 +196,7 @@ body { background: var(--card); border: var(--border); border-radius: 999px; - box-shadow: 0.2rem 0.2rem 0 #888; + box-shadow: 0.2rem 0.2rem 0 var(--shadow-color); font-size: 0.85rem; padding: 0.125rem 0.75rem; } @@ -156,24 +232,24 @@ body { } .machine[data-status="up"] { - background: #d9f2e0; + background: var(--status-up-bg); } .machine[data-status="degraded"] { - background: #fff3b8; + background: var(--status-degraded-bg); } .machine[data-status="down"] { - background: #2b2b2b; - color: #f2f2f2; + background: var(--status-down-bg); + color: var(--ink-invert); } .machine[data-status="unknown"] { - background-color: #e4e4e4; + background-color: var(--status-unknown-bg); background-image: linear-gradient( 115deg, transparent 35%, - rgba(255, 255, 255, 0.75) 50%, + var(--sheen) 50%, transparent 65% ); background-size: 250% 100%; @@ -199,11 +275,11 @@ body { } .machine[data-status="down"] .machine-ip { - color: #bbb; + color: var(--muted-5); } .machine[data-status="down"] .dot { - border-color: #888; + border-color: var(--muted-2); } .machine[data-status="down"] .popover { @@ -211,7 +287,7 @@ body { } .machine[data-status="down"] .popover .dot { - border-color: #111; + border-color: var(--ink); } .machine:hover, @@ -222,7 +298,7 @@ body { .machine:active { transform: translate(0.15rem, 0.15rem); - box-shadow: 0.2rem 0.2rem 0 #888; + box-shadow: 0.2rem 0.2rem 0 var(--shadow-color); } .machine:active:has(.refresh-btn:active) { @@ -257,21 +333,21 @@ body { padding: 0; border: var(--border); border-radius: var(--radius); - background: #fff; - color: #111; + background: var(--surface); + color: var(--ink); cursor: pointer; - box-shadow: 0.125rem 0.125rem 0 #888; + box-shadow: 0.125rem 0.125rem 0 var(--shadow-color); transition: transform 0.05s ease, box-shadow 0.05s ease; } .refresh-btn:hover { transform: translate(-0.0625rem, -0.0625rem); - box-shadow: 0.1875rem 0.1875rem 0 #888; + box-shadow: 0.1875rem 0.1875rem 0 var(--shadow-color); } .refresh-btn:active { transform: translate(0.0625rem, 0.0625rem); - box-shadow: 0 0 0 #888; + box-shadow: 0 0 0 var(--shadow-color); } .refresh-btn iconify-icon { @@ -282,7 +358,7 @@ body { font-family: var(--mono); font-weight: 600; font-size: 0.8rem; - color: #555; + color: var(--muted-4); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; @@ -307,7 +383,7 @@ body { border: var(--border); border-radius: 999px; padding: 0.125rem 0.5rem; - background: #fff; + background: var(--surface); color: var(--ink); } @@ -319,34 +395,34 @@ body { } .chip.ok { - background: #c7ecd3; - color: #0a5c22; + background: var(--ok-soft); + color: var(--ok-ink); } .chip.fail { - background: #ffd6d6; - color: #9c0000; + background: var(--fail-soft); + color: var(--fail-ink); } .chip.na, .chip.off { - background: #e5e5e5; - color: #666; + background: var(--surface-muted); + color: var(--muted-4); } .chip.unknown, .chip.na { - background-color: #e5e5e5; + background-color: var(--surface-muted); background-image: linear-gradient( 115deg, transparent 35%, - rgba(255, 255, 255, 0.9) 50%, + var(--sheen-strong) 50%, transparent 65% ); background-size: 250% 100%; background-repeat: no-repeat; animation: glimmer 2.4s ease-in-out infinite; - color: #777; + color: var(--muted); } .chip-pop { @@ -356,11 +432,11 @@ body { transform: translate(-50%, calc(-100% - 0.5rem)); z-index: 300; pointer-events: none; - background: #111; - color: #f2f2f2; + background: var(--dark-surface); + color: var(--ink-invert); border: var(--border); border-radius: 0.375rem; - box-shadow: 0.1875rem 0.1875rem 0 #888; + box-shadow: 0.1875rem 0.1875rem 0 var(--shadow-color); padding: 0.25rem 0.5625rem; font-size: 0.72rem; font-weight: 700; @@ -370,9 +446,9 @@ body { } .machine[data-status="down"] .chip { - background: #2a2a2a; - color: #eee; - border-color: #555; + background: var(--down-chip-bg); + color: var(--down-chip-ink); + border-color: var(--down-chip-border); } .badge { @@ -387,22 +463,22 @@ body { .badge.up { background: var(--green); - color: #fff; + color: var(--white); } .badge.degraded { background: var(--yellow); - color: #111; + color: var(--on-accent); } .badge.down { - background: #2b2b2b; - color: #fff; + background: var(--status-down-bg); + color: var(--white); } .badge.unknown { - background: #bbb; - color: #111; + background: var(--muted-5); + color: var(--on-accent); } .dot { @@ -410,12 +486,12 @@ body { width: 0.875rem; height: 0.875rem; border-radius: 50%; - border: 2px solid #111; + border: 2px solid var(--ink); } .dot.up { background: var(--green); - box-shadow: 0 0 0.5rem 0.0625rem rgba(26, 173, 75, 0.7); + box-shadow: 0 0 0.5rem 0.0625rem var(--green-glow); } .dot.degraded { @@ -423,11 +499,11 @@ body { } .dot.down { - background: #2b2b2b; + background: var(--status-down-bg); } .dot.unknown { - background: #ccc; + background: var(--muted-dot); } .popover { @@ -474,7 +550,7 @@ body { gap: 0.375rem 0.75rem; padding-bottom: 0.625rem; margin-bottom: 0.625rem; - border-bottom: 1px dashed #bbb; + border-bottom: 1px dashed var(--border-muted); } .popover-status { @@ -505,7 +581,7 @@ body { font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: #888; + color: var(--muted-2); } .popover-meta-val { @@ -526,7 +602,7 @@ body { text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.68rem; - color: #888; + color: var(--muted-2); padding: 0.15rem 0.625rem 0.15rem 0; white-space: nowrap; vertical-align: top; @@ -539,10 +615,10 @@ body { } .script-out { - background: #1a1d1a; - color: #e9f7ea; + background: var(--code-bg); + color: var(--code-ink); border-radius: 0.5rem; - border: 1px solid #2a2e2a; + border: 1px solid var(--code-border); padding: 0.5rem 0.625rem; font-family: var(--mono); font-weight: 600; @@ -562,7 +638,7 @@ body { .modal-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.35); + background: var(--overlay); display: flex; align-items: center; justify-content: center; @@ -599,7 +675,7 @@ body { background: var(--card); border: var(--border); border-radius: 0.625rem; - box-shadow: 0.2rem 0.2rem 0 #888; + box-shadow: 0.2rem 0.2rem 0 var(--shadow-color); font-size: 1.2rem; font-weight: 700; width: 2.125rem; @@ -638,7 +714,7 @@ body { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; - color: #888; + color: var(--muted-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -678,7 +754,7 @@ body { width: 0.5rem; height: 0.5rem; border-radius: 50%; - border: 1.5px solid #111; + border: 1.5px solid var(--border-ink); } .metric-latest { @@ -689,8 +765,8 @@ body { } .metric-latest.out-of-range { - color: #e5484d; - text-decoration: underline wavy #e5484d; + color: var(--red); + text-decoration: underline wavy var(--red); text-underline-offset: 0.15em; } @@ -715,7 +791,7 @@ body { top: 0; bottom: 0; width: 1px; - background: #111; + background: var(--ink); opacity: 0.35; pointer-events: none; } @@ -725,8 +801,8 @@ body { width: 0.5rem; height: 0.5rem; border-radius: 50%; - background: #111; - border: 2px solid #fff; + background: var(--ink); + border: 2px solid var(--white); transform: translate(-50%, -50%); pointer-events: none; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); @@ -735,8 +811,8 @@ body { .plot-tip { position: fixed; transform: translate(-50%, calc(-100% - 0.375rem)); - background: #111; - color: #fff; + background: var(--dark-surface); + color: var(--ink-invert); font-family: var(--mono); font-weight: 600; font-size: 0.62rem; @@ -753,7 +829,7 @@ body { margin-left: auto; font-family: var(--mono); font-size: 0.68rem; - color: #777; + color: var(--muted); } .plot-marks { @@ -770,7 +846,7 @@ body { font-family: var(--mono); font-weight: 600; font-size: 0.62rem; - color: #777; + color: var(--muted); white-space: nowrap; } @@ -785,16 +861,6 @@ body { font-weight: 600; } -@media (max-width: 640px) { - .modal-grid { - grid-template-columns: 1fr; - } - - .modal-cell.wide { - grid-column: auto; - } -} - .check-state { display: inline-flex; align-items: flex-start; @@ -812,7 +878,7 @@ body { height: 0.5rem; margin-top: 0.325rem; border-radius: 50%; - border: 1.5px solid #111; + border: 1.5px solid var(--border-ink); } .check-dot.ok { @@ -820,16 +886,16 @@ body { } .check-dot.fail { - background: #e5484d; + background: var(--red); } .check-dot.na, .check-dot.off { - background: #ccc; + background: var(--muted-dot); } .check-dot.unknown { - background: #ccc; + background: var(--muted-dot); animation: glimmer 2.4s ease-in-out infinite; } @@ -896,7 +962,7 @@ body { width: 0.25rem; height: 1.375rem; border-radius: 0.125rem; - border: 1px solid #111; + border: 1px solid var(--border-ink); } .tick.up { @@ -910,13 +976,13 @@ body { } .tick.down { - background: #2b2b2b; - border-color: color-mix(in srgb, #2b2b2b, white 30%); + background: var(--status-down-bg); + border-color: color-mix(in srgb, var(--status-down-bg), white 30%); } .tick.unknown { - background: #ccc; - border-color: color-mix(in srgb, #ccc, black 25%); + background: var(--muted-dot); + border-color: color-mix(in srgb, var(--muted-dot), black 25%); } .tick-mark { @@ -927,7 +993,7 @@ body { font-family: var(--mono); font-weight: 600; font-size: 0.62rem; - color: #777; + color: var(--muted); white-space: nowrap; pointer-events: none; } @@ -935,7 +1001,7 @@ body { .incidents { max-height: 13.75rem; overflow-y: auto; - background: #111; + background: var(--dark-surface); border-radius: 0.625rem; padding: 0.5rem 0.625rem; display: flex; @@ -948,7 +1014,7 @@ body { font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; - color: #999; + color: var(--muted-3); margin-bottom: 0.125rem; } @@ -959,7 +1025,7 @@ body { font-family: var(--mono); font-weight: 600; font-size: 0.72rem; - color: #e8e8e8; + color: var(--incidents-ink, #e8e8e8); } .incident-row .badge { @@ -969,7 +1035,7 @@ body { } .incident-row .muted { - color: #999; + color: var(--muted-3); margin-left: auto; } @@ -983,7 +1049,7 @@ body { font-family: var(--mono); font-weight: 600; font-size: 0.8rem; - color: #777; + color: var(--muted); } .footer span { @@ -996,8 +1062,8 @@ body { left: 50%; transform: translateX(-50%); z-index: 200; - background: #111; - color: #f2f2f2; + background: var(--dark-surface); + color: var(--ink-invert); border: var(--border); border-radius: var(--radius); box-shadow: var(--shadow); @@ -1017,3 +1083,42 @@ body { transform: translate(-50%, 0); } } + +/* small screens: bottom-sheet modal, hover popovers/tooltips disabled */ +@media (max-width: 640px) { + .header { + align-items: flex-start; + } + + .legend { + margin-left: 0; + } + + .modal-grid { + grid-template-columns: 1fr; + } + + .modal-cell.wide { + grid-column: auto; + } + + .popover, + .chip-pop, + .plot-tip { + display: none !important; + } + + .modal-overlay { + align-items: flex-end; + padding: 0; + } + + .modal { + width: 100%; + max-width: 100%; + max-height: 90vh; + border-radius: var(--radius) var(--radius) 0 0; + border-bottom: none; + box-shadow: 0 -0.4rem 1rem var(--overlay); + } +}