Add dark mode with system default and toggle

This commit is contained in:
Justin Oros
2026-10-07 14:34:05 -07:00
parent cb3cad3e1f
commit 50a060bacf
3 changed files with 210 additions and 24 deletions
+111 -9
View File
@@ -9,10 +9,68 @@
--jacket: #F0B400; --jacket: #F0B400;
--contract: #A23B72; --contract: #A23B72;
--error: #9B2C2C; --error: #9B2C2C;
--field: #FFFFFF;
--map-bg: #E4E8EA;
--button: #17212B;
--button-text: #FFFFFF;
--badge-bg: #F3E4EC;
--badge-open-bg: #FCEFC4;
--badge-open-text: #6B5000;
--legend-bg: rgba(252, 253, 253, 0.94);
--glint: rgba(255, 255, 255, 0.95);
color-scheme: light;
--font: "Barlow", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font: "Barlow", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--display: "Barlow Condensed", "Arial Narrow", "Roboto Condensed", sans-serif; --display: "Barlow Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
} }
:root[data-theme="dark"] {
--ink: #E3E9EE;
--muted: #9AA9B6;
--paper: #0E161D;
--panel: #141E27;
--line: #2A3946;
--strand: #2BB5C0;
--strand-deep: #6FD3DA;
--jacket: #F2BE1E;
--contract: #D86FA8;
--error: #F29A9A;
--field: #0F1922;
--map-bg: #1C252C;
--button: #E3E9EE;
--button-text: #141E27;
--badge-bg: #3A2231;
--badge-open-bg: #3A3010;
--badge-open-text: #F2D27A;
--legend-bg: rgba(20, 30, 39, 0.94);
--glint: rgba(255, 255, 255, 0.6);
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ink: #E3E9EE;
--muted: #9AA9B6;
--paper: #0E161D;
--panel: #141E27;
--line: #2A3946;
--strand: #2BB5C0;
--strand-deep: #6FD3DA;
--jacket: #F2BE1E;
--contract: #D86FA8;
--error: #F29A9A;
--field: #0F1922;
--map-bg: #1C252C;
--button: #E3E9EE;
--button-text: #141E27;
--badge-bg: #3A2231;
--badge-open-bg: #3A3010;
--badge-open-text: #F2D27A;
--legend-bg: rgba(20, 30, 39, 0.94);
--glint: rgba(255, 255, 255, 0.6);
color-scheme: dark;
}
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; margin: 0; } html, body { height: 100%; margin: 0; }
@@ -41,7 +99,7 @@ body {
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
} }
.map { height: 100%; min-height: 320px; background: #E4E8EA; } .map { height: 100%; min-height: 320px; background: var(--map-bg); }
h1 { h1 {
margin: 0; margin: 0;
@@ -60,7 +118,7 @@ h1 {
font: inherit; font: inherit;
padding: 12px 14px; padding: 12px 14px;
color: var(--ink); color: var(--ink);
background: #fff; background: var(--field);
border: 1.5px solid var(--line); border: 1.5px solid var(--line);
border-radius: 6px; border-radius: 6px;
} }
@@ -74,17 +132,61 @@ h1 {
.search button { .search button {
font: 600 1rem var(--font); font: 600 1rem var(--font);
padding: 12px 16px; padding: 12px 16px;
color: #fff; color: var(--button-text);
background: var(--ink); background: var(--button);
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
} }
.search button:hover { background: var(--strand-deep); } .search button:hover { color: var(--button-text); background: var(--strand); }
.search button:focus-visible { outline: 3px solid var(--jacket); outline-offset: 2px; } .search button:focus-visible { outline: 3px solid var(--jacket); outline-offset: 2px; }
.search button[disabled] { opacity: 0.6; cursor: progress; } .search button[disabled] { opacity: 0.6; cursor: progress; }
.top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.theme {
flex-shrink: 0;
margin-top: 4px;
padding: 5px 10px;
font: 500 0.85rem var(--font);
color: var(--ink);
background: transparent;
border: 1px solid var(--line);
border-radius: 14px;
cursor: pointer;
}
.theme:hover { border-color: var(--strand); }
.theme:focus-visible { outline: 3px solid var(--jacket); outline-offset: 2px; }
.leaflet-container { background: var(--map-bg); font-family: var(--font); }
.leaflet-tooltip {
color: var(--ink);
background: var(--panel);
border-color: var(--line);
}
.leaflet-tooltip-top::before { border-top-color: var(--panel); }
.leaflet-tooltip-bottom::before { border-bottom-color: var(--panel); }
.leaflet-tooltip-left::before { border-left-color: var(--panel); }
.leaflet-tooltip-right::before { border-right-color: var(--panel); }
.leaflet-bar a,
.leaflet-bar a:hover {
color: var(--ink);
background: var(--panel);
border-bottom-color: var(--line);
}
.leaflet-control-attribution.leaflet-control {
color: var(--muted);
background: var(--legend-bg);
}
.leaflet-control-attribution a { color: var(--strand-deep); }
.result { display: flex; flex-direction: column; gap: 26px; } .result { display: flex; flex-direction: column; gap: 26px; }
.result:empty { display: none; } .result:empty { display: none; }
@@ -119,7 +221,7 @@ h1 {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 30%; width: 30%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.95), transparent); background: linear-gradient(90deg, transparent, var(--glint), transparent);
transform: translateX(-120%); transform: translateX(-120%);
animation: pulse 1.4s ease-out 0.15s 1 forwards; animation: pulse 1.4s ease-out 0.15s 1 forwards;
} }
@@ -180,11 +282,11 @@ h1 {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: var(--contract); color: var(--contract);
background: #F3E4EC; background: var(--badge-bg);
border-radius: 10px; border-radius: 10px;
} }
.badge.open { color: #6B5000; background: #FCEFC4; } .badge.open { color: var(--badge-open-text); background: var(--badge-open-bg); }
.result a, .meta a { color: var(--strand-deep); } .result a, .meta a { color: var(--strand-deep); }
@@ -214,7 +316,7 @@ h1 {
padding: 8px 10px; padding: 8px 10px;
font: 0.82rem var(--font); font: 0.82rem var(--font);
color: var(--ink); color: var(--ink);
background: rgba(252, 253, 253, 0.94); background: var(--legend-bg);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
} }
+6 -1
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<script>try{var t=localStorage.getItem("theme");if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}</script>
<title>Got Fiber</title> <title>Got Fiber</title>
<meta name="description" content="Check whether fiber internet is reported at a US address and see broadband contracts and bids within 50 miles."> <meta name="description" content="Check whether fiber internet is reported at a US address and see broadband contracts and bids within 50 miles.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='9' fill='%23F0B400' stroke='%2317212B' stroke-width='4'/%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='9' fill='%23F0B400' stroke='%2317212B' stroke-width='4'/%3E%3C/svg%3E">
@@ -16,7 +18,10 @@
<main class="app"> <main class="app">
<aside class="panel"> <aside class="panel">
<header> <header>
<h1>Got Fiber?</h1> <div class="top">
<h1>Got Fiber?</h1>
<button id="theme" class="theme" type="button" aria-pressed="false">Dark mode</button>
</div>
<p class="lede">Enter a home address to see if fiber internet is reported there, who offers it, and which broadband contracts and bids are within 50 miles.</p> <p class="lede">Enter a home address to see if fiber internet is reported there, who offers it, and which broadband contracts and bids are within 50 miles.</p>
</header> </header>
<form id="search" class="search"> <form id="search" class="search">
+93 -14
View File
@@ -1,6 +1,7 @@
const RADIUS_KM = 80.4672; const RADIUS_KM = 80.4672;
const KM_PER_MI = 1.609344; const KM_PER_MI = 1.609344;
const COLORS = { strand: "#0A8F99", jacket: "#F0B400", ink: "#17212B", contract: "#A23B72" }; let COLORS = { strand: "#0A8F99", jacket: "#F0B400", ink: "#17212B", contract: "#A23B72" };
const DARK_QUERY = window.matchMedia("(prefers-color-scheme: dark)");
const STATUS = { const STATUS = {
open: "Open for bids", open: "Open for bids",
proposed: "Proposed", proposed: "Proposed",
@@ -89,32 +90,106 @@ function geomDistanceKm(geom, latlng) {
return best; return best;
} }
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function savedTheme() {
try {
const t = localStorage.getItem("theme");
return t === "dark" || t === "light" ? t : null;
} catch {
return null;
}
}
function currentTheme() {
return savedTheme() || S.themeOverride || (DARK_QUERY.matches ? "dark" : "light");
}
function refreshColors() {
COLORS = {
strand: cssVar("--strand") || COLORS.strand,
jacket: cssVar("--jacket") || COLORS.jacket,
ink: cssVar("--ink") || COLORS.ink,
contract: cssVar("--contract") || COLORS.contract
};
}
function setTiles() {
const dark = currentTheme() === "dark";
const shade = dark ? "Dark" : "Light";
const esri = "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/";
if (S.tiles) S.tiles.forEach(t => S.map.removeLayer(t));
S.tiles = [
L.tileLayer(esri + `World_${shade}_Gray_Base/MapServer/tile/{z}/{y}/{x}`, {
maxZoom: 16,
attribution: "Tiles &copy; Esri, HERE, Garmin, &copy; OpenStreetMap contributors"
}),
L.tileLayer(esri + `World_${shade}_Gray_Reference/MapServer/tile/{z}/{y}/{x}`, { maxZoom: 16, pane: "labels" })
];
S.tiles.forEach(t => t.addTo(S.map));
}
function renderLegend() {
if (!S.legendEl) return;
S.legendEl.innerHTML =
`<div><i style="background:${COLORS.strand};opacity:.55"></i>Fiber reported</div>` +
`<div><i style="background:${COLORS.contract};opacity:.55"></i>Contract or bid area</div>` +
`<div><i style="border:2px dashed ${COLORS.ink}"></i>50 miles</div>`;
}
function updateToggle() {
const btn = $("theme");
if (!btn) return;
const dark = currentTheme() === "dark";
btn.textContent = dark ? "Light mode" : "Dark mode";
btn.setAttribute("aria-pressed", String(dark));
}
function applyTheme() {
const saved = savedTheme() || S.themeOverride;
if (saved) document.documentElement.dataset.theme = saved;
else delete document.documentElement.dataset.theme;
refreshColors();
setTiles();
renderLegend();
updateToggle();
if (S.last) {
const { place, at, region, bead, deals } = S.last;
drawRegion(region, bead);
drawContracts(deals.items);
drawFocus(place, at);
}
}
function toggleTheme() {
const next = currentTheme() === "dark" ? "light" : "dark";
S.themeOverride = next;
try {
localStorage.setItem("theme", next);
} catch {}
applyTheme();
}
function initMap() { function initMap() {
const map = L.map("map", { preferCanvas: true }).setView([34.3, -111.7], 6); const map = L.map("map", { preferCanvas: true }).setView([34.3, -111.7], 6);
const esri = "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/"; S.map = map;
L.tileLayer(esri + "World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}", {
maxZoom: 16,
attribution: "Tiles &copy; Esri, HERE, Garmin, &copy; OpenStreetMap contributors"
}).addTo(map);
const labels = map.createPane("labels"); const labels = map.createPane("labels");
labels.style.zIndex = 450; labels.style.zIndex = 450;
labels.style.pointerEvents = "none"; labels.style.pointerEvents = "none";
L.tileLayer(esri + "World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}", { maxZoom: 16, pane: "labels" }).addTo(map);
S.layers.bead = L.layerGroup().addTo(map); S.layers.bead = L.layerGroup().addTo(map);
S.layers.fiber = L.layerGroup().addTo(map); S.layers.fiber = L.layerGroup().addTo(map);
S.layers.contracts = L.layerGroup().addTo(map); S.layers.contracts = L.layerGroup().addTo(map);
S.layers.focus = L.layerGroup().addTo(map); S.layers.focus = L.layerGroup().addTo(map);
const legend = L.control({ position: "bottomright" }); const legend = L.control({ position: "bottomright" });
legend.onAdd = () => { legend.onAdd = () => {
const d = L.DomUtil.create("div", "legend"); S.legendEl = L.DomUtil.create("div", "legend");
d.innerHTML = renderLegend();
`<div><i style="background:${COLORS.strand};opacity:.55"></i>Fiber reported</div>` + return S.legendEl;
`<div><i style="background:${COLORS.contract};opacity:.55"></i>Contract or bid area</div>` +
`<div><i style="border:2px dashed ${COLORS.ink}"></i>50 miles</div>`;
return d;
}; };
legend.addTo(map); legend.addTo(map);
S.map = map; applyTheme();
} }
async function geocodeEsri(q) { async function geocodeEsri(q) {
@@ -667,6 +742,7 @@ async function check(q) {
if (bead && (S.manifest.beadStates || []).includes(place.state)) { if (bead && (S.manifest.beadStates || []).includes(place.state)) {
deals.items = deals.items.filter(it => it.program !== "BEAD"); deals.items = deals.items.filter(it => it.program !== "BEAD");
} }
S.last = { place, at, region, bead, deals };
drawRegion(region, bead); drawRegion(region, bead);
drawContracts(deals.items); drawContracts(deals.items);
drawFocus(place, at); drawFocus(place, at);
@@ -684,7 +760,10 @@ async function check(q) {
} }
async function main() { async function main() {
refreshColors();
initMap(); initMap();
$("theme").addEventListener("click", toggleTheme);
DARK_QUERY.addEventListener("change", () => { if (!savedTheme() && !S.themeOverride) applyTheme(); });
S.manifest = (await getJSON("data/manifest.json")) || { states: [], contractStates: [], fineRes: 8, fineShardRes: 5, coarseRes: 6, coarseShardRes: 3 }; S.manifest = (await getJSON("data/manifest.json")) || { states: [], contractStates: [], fineRes: 8, fineShardRes: 5, coarseRes: 6, coarseShardRes: 3 };
renderMeta(); renderMeta();
$("search").addEventListener("submit", e => { $("search").addEventListener("submit", e => {