web: add site-wide dark mode with shared theme tokens

Bootstrap data-bs-theme + localStorage toggle in layout chrome; map
console CSS (users/sweatbox/airport editor) onto shared --ofs-console-*
tokens; swap OSM basemap for dark maps. Include PE and webjs coverage,
and the approved sweatbox TWRTrainer P2 command-parity design doc.
This commit is contained in:
Reese Norris
2026-07-27 17:59:01 -04:00
parent 9a547c8fa6
commit d14dab8736
21 changed files with 2286 additions and 140 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -322,6 +322,51 @@ func TestAuthedDashboardRendersUser(t *testing.T) {
}
}
// TestThemeChromeInLayout ensures every layout-based page ships the shared
// dark-mode assets and toggle (no per-page theme wiring).
func TestThemeChromeInLayout(t *testing.T) {
ts := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /login status %d", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`/static/js/openfsd/theme.js`,
`/static/css/openfsd/theme.css`,
`data-js="theme-toggle"`,
`ofs-theme-toggle`,
// Theme-aware nav/control classes (not btn-outline-dark)
`btn-outline-secondary`,
} {
if !strings.Contains(body, want) {
t.Fatalf("login HTML missing %q; snippet: %s", want, clip(body, 500))
}
}
if strings.Contains(body, "btn-outline-dark") {
t.Fatal("login HTML still uses btn-outline-dark (poor dark-mode contrast)")
}
// Static assets must be served (embed).
for _, path := range []string{
"/static/js/openfsd/theme.js",
"/static/css/openfsd/theme.css",
} {
req = httptest.NewRequest(http.MethodGet, path, nil)
w = httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s status %d", path, w.Code)
}
if w.Body.Len() < 32 {
t.Fatalf("GET %s body too small", path)
}
}
}
func TestAPICookieAuthRequiresCSRF(t *testing.T) {
ts := newTestServer(t)
user := createTestUser(t, ts, "pw", int(protocol.NetworkRatingSupervisor))

View File

@@ -6,21 +6,21 @@
* height (required for correct OSM tile coverage).
*/
/* --- tokens (mirrors sweatbox density; --apted-* namespace) --- */
/* --- tokens (map to shared theme.css --ofs-console-*) --- */
.apted {
--apted-gap: 0.5rem;
--apted-pad: 0.5rem 0.75rem;
--apted-border: #c5c9ce;
--apted-border-strong: #8b9198;
--apted-bg: #f4f5f6;
--apted-panel: #fff;
--apted-muted: #5c636a;
--apted-accent: #1a1a1a;
--apted-sel: #dce8f5;
--apted-sel-border: #4a7ab0;
--apted-danger: #842029;
--apted-ok: #0f5132;
--apted-warn-bg: #fff3cd;
--apted-border: var(--ofs-console-border);
--apted-border-strong: var(--ofs-console-border-strong);
--apted-bg: var(--ofs-console-bg);
--apted-panel: var(--ofs-console-panel);
--apted-muted: var(--ofs-console-muted);
--apted-accent: var(--ofs-console-accent);
--apted-sel: var(--ofs-console-sel);
--apted-sel-border: var(--ofs-console-sel-border);
--apted-danger: var(--ofs-console-danger);
--apted-ok: var(--ofs-console-ok);
--apted-warn-bg: var(--ofs-console-warn-bg);
--apted-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--apted-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--apted-fs: 0.8125rem;
@@ -114,27 +114,27 @@
}
.apted-chip.is-dirty {
border-color: #c9a227;
border-color: var(--ofs-console-warn-border);
background: var(--apted-warn-bg);
color: #664d03;
color: var(--ofs-console-warn-fg);
}
.apted-chip-issues.is-ok {
border-color: #a3cfbb;
background: #d1e7dd;
border-color: var(--ofs-console-ok-border);
background: var(--ofs-console-ok-bg);
color: var(--apted-ok);
}
.apted-chip-issues.is-err {
border-color: #f1aeb5;
background: #f8d7da;
border-color: var(--ofs-console-err-border);
background: var(--ofs-console-err-bg);
color: var(--apted-danger);
}
.apted-chip-issues.is-warn {
border-color: #c9a227;
border-color: var(--ofs-console-warn-border);
background: var(--apted-warn-bg);
color: #664d03;
color: var(--ofs-console-warn-fg);
}
.apted-chip-issues.is-empty {
@@ -152,23 +152,23 @@
font-weight: 700;
line-height: 1.35;
vertical-align: middle;
background: #e9ecef;
background: var(--ofs-console-chrome);
color: var(--apted-muted);
}
.apted-tab-badge.is-ok {
background: #d1e7dd;
background: var(--ofs-console-ok-bg);
color: var(--apted-ok);
}
.apted-tab-badge.is-err {
background: #f8d7da;
background: var(--ofs-console-err-bg);
color: var(--apted-danger);
}
.apted-tab-badge.is-warn {
background: var(--apted-warn-bg);
color: #664d03;
color: var(--ofs-console-warn-fg);
}
.apted-flash {
@@ -199,15 +199,15 @@
}
.apted-flash-ok {
background: #d1e7dd;
background: var(--ofs-console-ok-bg);
color: var(--apted-ok);
border-color: #a3cfbb;
border-color: var(--ofs-console-ok-border);
}
.apted-flash-err {
background: #f8d7da;
background: var(--ofs-console-err-bg);
color: var(--apted-danger);
border-color: #f1aeb5;
border-color: var(--ofs-console-err-border);
}
/* --- toolbar --- */
@@ -319,7 +319,8 @@
padding: 0.2rem 0.35rem;
width: 100%;
min-width: 0;
background: #fff;
background: var(--ofs-console-input);
color: var(--apted-accent);
}
.apted-field-inline > span {
@@ -343,6 +344,8 @@
border-radius: 2px;
padding: 0.2rem 0.35rem;
max-width: 8rem;
background: var(--ofs-console-input);
color: var(--apted-accent);
}
.apted-icon-btn {
@@ -412,7 +415,7 @@
/* Map full-bleed (Leaflet owns the box when enhanced) */
.apted-map {
min-height: 16rem;
background: #dfe6ea;
background: var(--ofs-console-map);
border: 1px solid var(--apted-border-strong);
border-radius: 2px;
display: flex;
@@ -467,7 +470,7 @@
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid var(--apted-border);
background: #e9ecef;
background: var(--ofs-console-chrome);
flex-shrink: 0;
}
@@ -489,7 +492,7 @@
.apted-tab:hover {
color: var(--apted-accent);
background: #dde1e5;
background: var(--ofs-console-tab-hover);
}
.apted-tab.is-active,
@@ -535,7 +538,7 @@
gap: 0.25rem 0.5rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--apted-border);
background: #f0f2f4;
background: var(--ofs-console-chrome-soft);
}
.apted-panel-hd h2 {
@@ -622,7 +625,7 @@
}
.apted-list-item:hover {
background: #f0f4f8;
background: var(--ofs-console-hover);
}
.apted-list-item.is-selected,
@@ -640,15 +643,15 @@
}
.apted-err-list-warn {
color: #664d03;
color: var(--ofs-console-warn-fg);
background: var(--apted-warn-bg);
border: 1px solid #e6d28a;
border: 1px solid var(--ofs-console-warn-border);
border-radius: 2px;
padding: 0.35rem 0.5rem 0.35rem 1.25rem;
}
.apted-soft-hd {
color: #664d03;
color: var(--ofs-console-warn-fg);
}
.apted-soft-note {
@@ -677,9 +680,9 @@
}
.apted-server-stale {
color: #664d03;
color: var(--ofs-console-warn-fg);
background: var(--apted-warn-bg);
border: 1px solid #e6d28a;
border: 1px solid var(--ofs-console-warn-border);
border-radius: 2px;
padding: 0.25rem 0.4rem;
}
@@ -710,7 +713,7 @@
}
.apted-mini-table th {
background: #e9ecef;
background: var(--ofs-console-chrome);
font-weight: 600;
text-align: center;
}
@@ -724,7 +727,8 @@
width: 100%;
resize: vertical;
min-height: 5rem;
background: #f8f9fa;
background: var(--ofs-console-help);
color: var(--apted-accent);
}
/* Field blocks (inspector / raw panels) */
@@ -751,6 +755,8 @@
padding: 0.35rem 0.45rem;
width: 100%;
max-width: 100%;
background: var(--ofs-console-input);
color: var(--apted-accent);
}
.apted-field textarea {

View File

@@ -1,17 +1,18 @@
/*
* Sweatbox instructor manual — readable document layout.
* Separate from the dense operator console (sweatbox.css).
* Colors from shared theme.css tokens.
*/
.sbxm {
--sbxm-max: 52rem;
--sbxm-toc-w: 14rem;
--sbxm-gap: 1.5rem;
--sbxm-border: #d0d4d9;
--sbxm-muted: #5c636a;
--sbxm-bg: #f8f9fa;
--sbxm-panel: #fff;
--sbxm-link: #0b57d0;
--sbxm-border: var(--ofs-console-border);
--sbxm-muted: var(--ofs-console-muted);
--sbxm-bg: var(--ofs-console-bg);
--sbxm-panel: var(--ofs-console-panel);
--sbxm-link: var(--ofs-console-link);
--sbxm-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--sbxm-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -19,7 +20,7 @@
font-family: var(--sbxm-font);
font-size: 0.9375rem;
line-height: 1.55;
color: #1a1a1a;
color: var(--ofs-console-accent);
background: var(--sbxm-bg);
max-width: none;
margin: 0;
@@ -175,7 +176,7 @@
.sbxm-doc code {
font-family: var(--sbxm-mono);
font-size: 0.88em;
background: #f0f2f4;
background: var(--ofs-console-code-bg);
padding: 0.08em 0.28em;
border-radius: 0.2rem;
}
@@ -205,7 +206,7 @@
}
.sbxm-table th {
background: #eef1f4;
background: var(--ofs-console-chrome);
font-weight: 650;
}
@@ -227,7 +228,7 @@
.sbxm-dl dd {
margin: 0.15rem 0 0.35rem 0;
color: #2a2a2a;
color: var(--ofs-console-accent);
}
.sbxm-note {
@@ -257,6 +258,7 @@
@media print {
.sbxm {
background: #fff;
color: #000;
padding: 0;
}
@@ -267,6 +269,7 @@
.sbxm-doc {
border: none;
padding: 0;
background: #fff;
}
.sbxm-kicker {

View File

@@ -8,21 +8,21 @@
* the control surface fits one viewport rather than page-scrolling.
*/
/* --- tokens --- */
/* --- tokens (map to shared theme.css --ofs-console-*) --- */
.sbx {
--sbx-gap: 0.5rem;
--sbx-pad: 0.5rem 0.75rem;
--sbx-border: #c5c9ce;
--sbx-border-strong: #8b9198;
--sbx-bg: #f4f5f6;
--sbx-panel: #fff;
--sbx-muted: #5c636a;
--sbx-accent: #1a1a1a;
--sbx-sel: #dce8f5;
--sbx-sel-border: #4a7ab0;
--sbx-danger: #842029;
--sbx-ok: #0f5132;
--sbx-warn-bg: #fff3cd;
--sbx-border: var(--ofs-console-border);
--sbx-border-strong: var(--ofs-console-border-strong);
--sbx-bg: var(--ofs-console-bg);
--sbx-panel: var(--ofs-console-panel);
--sbx-muted: var(--ofs-console-muted);
--sbx-accent: var(--ofs-console-accent);
--sbx-sel: var(--ofs-console-sel);
--sbx-sel-border: var(--ofs-console-sel-border);
--sbx-danger: var(--ofs-console-danger);
--sbx-ok: var(--ofs-console-ok);
--sbx-warn-bg: var(--ofs-console-warn-bg);
--sbx-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--sbx-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--sbx-fs: 0.8125rem;
@@ -84,21 +84,21 @@
}
.sbx-flash-ok {
background: #d1e7dd;
background: var(--ofs-console-ok-bg);
color: var(--sbx-ok);
border-color: #a3cfbb;
border-color: var(--ofs-console-ok-border);
}
.sbx-flash-err {
background: #f8d7da;
background: var(--ofs-console-err-bg);
color: var(--sbx-danger);
border-color: #f1aeb5;
border-color: var(--ofs-console-err-border);
}
.sbx-flash-warn {
background: var(--sbx-warn-bg);
color: #664d03;
border-color: #ffecb5;
color: var(--ofs-console-warn-fg);
border-color: var(--ofs-console-warn-border);
}
/* --- status strip --- */
@@ -202,7 +202,7 @@
gap: 0.25rem 0.5rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--sbx-border);
background: #e9ecef;
background: var(--ofs-console-chrome);
}
.sbx-panel-hd h2 {
@@ -249,7 +249,7 @@
.sbx-table th,
.sbx-table td {
padding: 0.2rem 0.35rem;
border-bottom: 1px solid #e2e4e7;
border-bottom: 1px solid var(--ofs-console-row-border);
vertical-align: middle;
white-space: nowrap;
}
@@ -258,7 +258,7 @@
position: sticky;
top: 0;
z-index: 1;
background: #e9ecef;
background: var(--ofs-console-chrome);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
@@ -272,7 +272,7 @@
}
.sbx-table tbody tr:hover {
background: #f0f4f8;
background: var(--ofs-console-hover);
}
.sbx-table tbody tr.sbx-row-selected {
@@ -356,7 +356,8 @@
padding: 0.25rem 0.4rem;
border: 1px solid var(--sbx-border-strong);
border-radius: 2px;
background: #fff;
background: var(--ofs-console-input);
color: var(--sbx-accent);
width: 100%;
}
@@ -394,7 +395,7 @@
.sbx-help {
margin: 0;
padding: 0.3rem 0.4rem;
background: #f8f9fa;
background: var(--ofs-console-help);
border: 1px solid var(--sbx-border);
border-radius: 2px;
font-size: 0.68rem;

View File

@@ -0,0 +1,124 @@
/*
* openfsd shared theme tokens.
*
* Bootstrap 5.3 handles data-bs-theme for its own components.
* This file owns site chrome + console design tokens so Users / Sweatbox /
* Airport Editor / Manual dark modes stay in one place (no per-page copy).
*/
/* --- Bootstrap root: native form controls follow theme --- */
:root,
[data-bs-theme="light"] {
color-scheme: light;
}
[data-bs-theme="dark"] {
color-scheme: dark;
}
/* --- Shared console tokens (light defaults) --- */
:root,
[data-bs-theme="light"] {
--ofs-console-bg: #f4f5f6;
--ofs-console-panel: #fff;
--ofs-console-chrome: #e9ecef;
--ofs-console-chrome-soft: #f0f2f4;
--ofs-console-hover: #f0f4f8;
--ofs-console-input: #fff;
--ofs-console-help: #f8f9fa;
--ofs-console-map: #dfe6ea;
--ofs-console-border: #c5c9ce;
--ofs-console-border-strong: #8b9198;
--ofs-console-row-border: #e2e4e7;
--ofs-console-muted: #5c636a;
--ofs-console-accent: #1a1a1a;
--ofs-console-sel: #dce8f5;
--ofs-console-sel-border: #4a7ab0;
--ofs-console-danger: #842029;
--ofs-console-ok: #0f5132;
--ofs-console-warn-fg: #664d03;
--ofs-console-warn-bg: #fff3cd;
--ofs-console-warn-border: #ffecb5;
--ofs-console-ok-bg: #d1e7dd;
--ofs-console-ok-border: #a3cfbb;
--ofs-console-err-bg: #f8d7da;
--ofs-console-err-border: #f1aeb5;
--ofs-console-link: #0b57d0;
--ofs-console-code-bg: #f0f2f4;
--ofs-console-tab-hover: #dde1e5;
}
/* --- Shared console tokens (dark) --- */
[data-bs-theme="dark"] {
--ofs-console-bg: #121417;
--ofs-console-panel: #1c1f24;
--ofs-console-chrome: #252a31;
--ofs-console-chrome-soft: #22262c;
--ofs-console-hover: #2a3140;
--ofs-console-input: #161a1f;
--ofs-console-help: #161a1f;
--ofs-console-map: #1a2228;
--ofs-console-border: #3a424c;
--ofs-console-border-strong: #5c6670;
--ofs-console-row-border: #2e343c;
--ofs-console-muted: #a8b0b8;
--ofs-console-accent: #e8eaed;
--ofs-console-sel: #1e3a5f;
--ofs-console-sel-border: #6ea8e0;
--ofs-console-danger: #f1aeb5;
--ofs-console-ok: #75b798;
--ofs-console-warn-fg: #ffda6a;
--ofs-console-warn-bg: #332701;
--ofs-console-warn-border: #664d03;
--ofs-console-ok-bg: #051b11;
--ofs-console-ok-border: #0f5132;
--ofs-console-err-bg: #2c0b0e;
--ofs-console-err-border: #842029;
--ofs-console-link: #6ea8fe;
--ofs-console-code-bg: #252a31;
--ofs-console-tab-hover: #2a3038;
}
/* --- Site header chrome --- */
.ofs-site-header {
background-color: var(--bs-body-bg);
border-bottom-color: var(--bs-border-color) !important;
}
.ofs-brand-link {
color: var(--bs-body-color);
font-weight: 600;
}
.ofs-brand-link:hover,
.ofs-brand-link:focus-visible {
color: var(--bs-body-color);
}
/* Logo mark: invert the black outer plate in dark mode; keep white inset + black plane */
[data-bs-theme="dark"] .ofs-brand-mark {
filter: invert(1);
}
/* Theme toggle */
.ofs-theme-toggle {
min-width: 2.25rem;
}
.ofs-theme-toggle .ofs-theme-icon-dark {
display: none;
}
[data-bs-theme="dark"] .ofs-theme-toggle .ofs-theme-icon-light {
display: none;
}
[data-bs-theme="dark"] .ofs-theme-toggle .ofs-theme-icon-dark {
display: inline;
}
/* Login card uses theme border/surface (no hardcoded dark-subtle) */
.ofs-login-card {
background-color: var(--bs-body-bg);
border-color: var(--bs-border-color) !important;
}

View File

@@ -1,17 +1,19 @@
/* users.css — dense utilitarian Users console (self-contained) */
/* users.css — dense utilitarian Users console (self-contained)
* Colors come from shared tokens in theme.css (--ofs-console-*). */
.usr {
--usr-gap: 0.5rem;
--usr-pad: 0.5rem 0.75rem;
--usr-border: #c5c9ce;
--usr-border-strong: #8b9198;
--usr-bg: #f4f5f6;
--usr-panel: #fff;
--usr-muted: #5c636a;
--usr-accent: #1a1a1a;
--usr-sel: #dce8f5;
--usr-sel-border: #4a7ab0;
--usr-danger: #842029;
--usr-ok: #0f5132;
--usr-border: var(--ofs-console-border);
--usr-border-strong: var(--ofs-console-border-strong);
--usr-bg: var(--ofs-console-bg);
--usr-panel: var(--ofs-console-panel);
--usr-muted: var(--ofs-console-muted);
--usr-accent: var(--ofs-console-accent);
--usr-sel: var(--ofs-console-sel);
--usr-sel-border: var(--ofs-console-sel-border);
--usr-danger: var(--ofs-console-danger);
--usr-ok: var(--ofs-console-ok);
--usr-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--usr-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--usr-fs: 0.8125rem;
@@ -72,19 +74,19 @@
font-size: var(--usr-fs-sm);
}
.usr-flash-ok {
background: #d1e7dd;
border-color: #a3cfbb;
background: var(--ofs-console-ok-bg);
border-color: var(--ofs-console-ok-border);
color: var(--usr-ok);
}
.usr-flash-err {
background: #f8d7da;
border-color: #f1aeb5;
background: var(--ofs-console-err-bg);
border-color: var(--ofs-console-err-border);
color: var(--usr-danger);
}
.usr-flash-warn {
background: #fff3cd;
border-color: #ffecb5;
color: #664d03;
background: var(--ofs-console-warn-bg);
border-color: var(--ofs-console-warn-border);
color: var(--ofs-console-warn-fg);
}
.usr-filters {
@@ -119,7 +121,7 @@
padding: 0.2rem 0.4rem;
border: 1px solid var(--usr-border);
border-radius: 2px;
background: #fff;
background: var(--ofs-console-input);
color: var(--usr-accent);
max-width: 100%;
}
@@ -165,7 +167,7 @@
gap: 0.25rem 0.5rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--usr-border);
background: #e9ecef;
background: var(--ofs-console-chrome);
}
.usr-panel-hd h2 {
margin: 0;
@@ -207,7 +209,7 @@
.usr-table th,
.usr-table td {
padding: 0.2rem 0.35rem;
border-bottom: 1px solid #e2e4e7;
border-bottom: 1px solid var(--ofs-console-row-border);
vertical-align: middle;
white-space: nowrap;
}
@@ -215,7 +217,7 @@
position: sticky;
top: 0;
z-index: 1;
background: #e9ecef;
background: var(--ofs-console-chrome);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
@@ -237,7 +239,7 @@
outline: 1px solid var(--usr-sel-border);
outline-offset: -1px;
}
.usr-table tbody tr:hover { background: #f0f4f8; }
.usr-table tbody tr:hover { background: var(--ofs-console-hover); }
.usr-table tbody tr.usr-row-selected:hover { background: var(--usr-sel); }
.usr-table .usr-cid {
font-family: var(--usr-mono);

View File

@@ -12,10 +12,19 @@ import {
SurfaceHold,
} from './model.js';
/** OSM Standard tiles (default). Attribution required — do not strip. */
/**
* OSM basemap URLs — keep in sync with OpenFSDTheme.osmBasemap (theme.js).
* Light: OSM Standard. Dark: CARTO Dark Matter (OSM data + CARTO style).
* Attribution required — do not strip.
*/
export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
export const OSM_DARK_TILE_URL =
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
export const OSM_DARK_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
/** Esri World Imagery (optional basemap). Attribution required. */
export const ESRI_TILE_URL =
@@ -27,6 +36,49 @@ export const ESRI_ATTRIBUTION =
export const BLANK_TILE_DATA_URI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
/**
* Effective UI theme for basemap selection.
* Prefers OpenFSDTheme (layout); falls back to data-bs-theme / light.
* @param {Document|null|undefined} [doc]
* @returns {"light"|"dark"}
*/
export function currentUiTheme(doc) {
const d = doc || (typeof globalThis !== 'undefined' ? globalThis.document : null);
const T = typeof globalThis !== 'undefined' ? globalThis.OpenFSDTheme : null;
if (T && typeof T.currentTheme === 'function') {
return T.currentTheme(d) === 'dark' ? 'dark' : 'light';
}
const el = d && d.documentElement;
const v = el && typeof el.getAttribute === 'function' ? el.getAttribute('data-bs-theme') : null;
return v === 'dark' ? 'dark' : 'light';
}
/**
* Create OSM (light) or CARTO dark OSM tile layer matching the UI theme.
* @param {typeof globalThis.L} L
* @param {"light"|"dark"|string} [theme]
* @returns {*} Leaflet tile layer
*/
export function createOsmTileLayer(L, theme) {
const t = theme === 'dark' ? 'dark' : 'light';
const T = typeof globalThis !== 'undefined' ? globalThis.OpenFSDTheme : null;
if (T && typeof T.createLeafletOsmLayer === 'function') {
return T.createLeafletOsmLayer(L, t);
}
// Fallback when theme.js is not loaded (Node tests / isolated pages).
if (t === 'dark') {
return L.tileLayer(OSM_DARK_TILE_URL, {
maxZoom: 20,
subdomains: 'abcd',
attribution: OSM_DARK_ATTRIBUTION,
});
}
return L.tileLayer(OSM_TILE_URL, {
maxZoom: 19,
attribution: OSM_ATTRIBUTION,
});
}
/**
* Style for a surface kind. Selected uses highlight border color.
* @param {string} kind
@@ -506,9 +558,11 @@ export function bindTextTooltip(layer, text, opts = {}) {
* Create blank tile layer for e2e (no network). Uses L.tileLayer with data-URI
* or L.gridLayer empty tiles when available.
* @param {typeof globalThis.L} L
* @param {{ theme?: "light"|"dark"|string }} [opts]
* @returns {*} Leaflet layer
*/
export function createBlankTileLayer(L) {
export function createBlankTileLayer(L, opts = {}) {
const bg = opts.theme === 'dark' ? '#1a2228' : '#dfe6ea';
if (typeof L.gridLayer === 'function') {
return L.gridLayer({
attribution: 'blank tiles (e2e)',
@@ -517,7 +571,7 @@ export function createBlankTileLayer(L) {
const tile = document.createElement('div');
tile.style.width = '256px';
tile.style.height = '256px';
tile.style.background = '#dfe6ea';
tile.style.background = bg;
return tile;
},
});
@@ -531,19 +585,18 @@ export function createBlankTileLayer(L) {
/**
* Create OSM + Esri basemap layers (or blank when testTiles).
* OSM tiles follow the UI theme (light OSM Standard / dark CARTO).
* @param {typeof globalThis.L} L
* @param {{ blank?: boolean }} [opts]
* @param {{ blank?: boolean, theme?: "light"|"dark"|string }} [opts]
* @returns {{ osm: *, esri: *, blank: *|null, defaultKey: 'osm'|'blank' }}
*/
export function createBaseLayers(L, opts = {}) {
const theme = opts.theme === 'dark' ? 'dark' : 'light';
if (opts.blank) {
const blank = createBlankTileLayer(L);
const blank = createBlankTileLayer(L, { theme });
return { osm: blank, esri: blank, blank, defaultKey: 'blank' };
}
const osm = L.tileLayer(OSM_TILE_URL, {
maxZoom: 19,
attribution: OSM_ATTRIBUTION,
});
const osm = createOsmTileLayer(L, theme);
const esri = L.tileLayer(ESRI_TILE_URL, {
maxZoom: 19,
attribution: ESRI_ATTRIBUTION,
@@ -554,10 +607,11 @@ export function createBaseLayers(L, opts = {}) {
/**
* Initialize Leaflet map on #apted-map (or given element).
* Attribution control remains visible; optional setPrefix("") only.
* Listens for openfsd:themechange to swap OSM light/dark tiles.
* @param {typeof globalThis.L} L
* @param {HTMLElement} mapEl
* @param {{ blankTiles?: boolean, center?: [number, number], zoom?: number }} [opts]
* @returns {{ map: *, baseLayers: ReturnType<typeof createBaseLayers>, activeBase: string, setBase: (key: string) => void }}
* @returns {{ map: *, baseLayers: ReturnType<typeof createBaseLayers>, activeBase: string, setBase: (key: string) => void, destroy: () => void }}
*/
export function createMap(L, mapEl, opts = {}) {
const center = opts.center || [30, 0];
@@ -577,7 +631,12 @@ export function createMap(L, mapEl, opts = {}) {
map.boxZoom.disable();
}
const baseLayers = createBaseLayers(L, { blank: !!opts.blankTiles });
const doc = (mapEl && mapEl.ownerDocument) || (typeof globalThis !== 'undefined' ? globalThis.document : null);
const initialTheme = currentUiTheme(doc);
const baseLayers = createBaseLayers(L, {
blank: !!opts.blankTiles,
theme: initialTheme,
});
let activeBase = baseLayers.defaultKey;
const layerByKey = {
osm: baseLayers.osm,
@@ -606,6 +665,58 @@ export function createMap(L, mapEl, opts = {}) {
}
}
/**
* Rebuild OSM (or blank) basemap when UI theme changes.
* Esri imagery is unchanged. Only re-adds the layer if OSM/blank is active.
* @param {"light"|"dark"|string} theme
*/
function applyOsmTheme(theme) {
const t = theme === 'dark' ? 'dark' : 'light';
const wasActive = activeBase === 'osm' || (opts.blankTiles && activeBase === 'blank');
const prev = layerByKey.osm;
if (prev && map.hasLayer(prev)) {
map.removeLayer(prev);
}
let next;
if (opts.blankTiles) {
next = createBlankTileLayer(L, { theme: t });
baseLayers.blank = next;
layerByKey.blank = next;
} else {
next = createOsmTileLayer(L, t);
}
baseLayers.osm = next;
layerByKey.osm = next;
if (wasActive) {
next.addTo(map);
if (typeof map.invalidateSize === 'function') {
map.invalidateSize({ animate: false });
}
}
}
const themeEvent =
(typeof globalThis !== 'undefined' &&
globalThis.OpenFSDTheme &&
globalThis.OpenFSDTheme.THEME_CHANGE_EVENT) ||
'openfsd:themechange';
function onThemeChange(ev) {
const theme =
(ev && ev.detail && ev.detail.theme) || currentUiTheme(doc);
applyOsmTheme(theme);
}
if (doc && typeof doc.addEventListener === 'function') {
doc.addEventListener(themeEvent, onThemeChange);
}
function destroy() {
if (doc && typeof doc.removeEventListener === 'function') {
doc.removeEventListener(themeEvent, onThemeChange);
}
}
// Force a remeasure after the first paint of this host (flex/grid settle).
if (typeof map.whenReady === 'function') {
map.whenReady(() => {
@@ -615,7 +726,16 @@ export function createMap(L, mapEl, opts = {}) {
});
}
return { map, baseLayers, get activeBase() { return activeBase; }, setBase };
return {
map,
baseLayers,
get activeBase() {
return activeBase;
},
setBase,
applyOsmTheme,
destroy,
};
}
/**

View File

@@ -183,7 +183,7 @@ function ensureModeButtons(group) {
for (const [mode, label] of modes) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-dark apted-mode-btn';
btn.className = 'btn btn-sm btn-outline-secondary apted-mode-btn';
btn.setAttribute('data-mode', mode);
btn.setAttribute('aria-pressed', mode === MODE_SELECT ? 'true' : 'false');
btn.title = `${MODE_LABELS[mode]} mode (key ${modes.findIndex((m) => m[0] === mode) + 1})`;

View File

@@ -30,11 +30,51 @@ document.addEventListener("DOMContentLoaded", async function () {
}
const map = L.map("map").setView([30, 0], 1);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map);
map.attributionControl.setPrefix("");
// Theme-aware OSM basemap (light Standard / dark CARTO). Shared with
// airport editor via OpenFSDTheme (theme.js).
let baseLayer = null;
function applyDashboardBasemap(theme) {
const next =
typeof OpenFSDTheme !== "undefined" &&
typeof OpenFSDTheme.createLeafletOsmLayer === "function"
? OpenFSDTheme.createLeafletOsmLayer(L, theme)
: L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
});
if (baseLayer) {
map.removeLayer(baseLayer);
}
baseLayer = next;
baseLayer.addTo(map);
}
const initialTheme =
typeof OpenFSDTheme !== "undefined" &&
typeof OpenFSDTheme.currentTheme === "function"
? OpenFSDTheme.currentTheme(document)
: (document.documentElement.getAttribute("data-bs-theme") === "dark"
? "dark"
: "light");
applyDashboardBasemap(initialTheme);
const themeEvent =
typeof OpenFSDTheme !== "undefined" && OpenFSDTheme.THEME_CHANGE_EVENT
? OpenFSDTheme.THEME_CHANGE_EVENT
: "openfsd:themechange";
document.addEventListener(themeEvent, function (ev) {
const theme =
(ev && ev.detail && ev.detail.theme) ||
(typeof OpenFSDTheme !== "undefined" &&
typeof OpenFSDTheme.currentTheme === "function"
? OpenFSDTheme.currentTheme(document)
: "light");
applyDashboardBasemap(theme);
});
const planeIcon = L.icon({
iconUrl: "/static/images/plane.png",
iconSize: [16, 16],

View File

@@ -0,0 +1,326 @@
/**
* openfsd color theme — Bootstrap 5.3 data-bs-theme + localStorage.
*
* Classic (non-module) script: load synchronously in <head> so the first paint
* already has the preferred theme (avoids light→dark flash). Toggle is
* progressive enhancement; without JS the page stays on the default light theme.
*
* Public API: globalThis.OpenFSDTheme
*/
(function (root) {
"use strict";
var STORAGE_KEY = "openfsd-theme";
/** Dispatched on document after data-bs-theme is applied (maps listen for basemap swap). */
var THEME_CHANGE_EVENT = "openfsd:themechange";
/**
* OSM basemap configs (light = OSM Standard; dark = CARTO Dark Matter, OSM data).
* Shared by dashboard + airport editor Leaflet maps.
*/
var OSM_BASEMAP_LIGHT = {
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
maxZoom: 19,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
};
var OSM_BASEMAP_DARK = {
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
maxZoom: 20,
subdomains: "abcd",
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>',
};
/**
* @param {string|null|undefined} value
* @returns {"light"|"dark"|null}
*/
function normalizeTheme(value) {
if (value === "dark" || value === "light") {
return value;
}
return null;
}
/**
* Resolve effective theme from stored preference and system preference.
* @param {string|null|undefined} stored
* @param {boolean} prefersDark
* @returns {"light"|"dark"}
*/
function resolveTheme(stored, prefersDark) {
var n = normalizeTheme(stored);
if (n) {
return n;
}
return prefersDark ? "dark" : "light";
}
/**
* @param {Storage|null|undefined} storage
* @returns {string|null}
*/
function readStored(storage) {
try {
if (!storage || typeof storage.getItem !== "function") {
return null;
}
return storage.getItem(STORAGE_KEY);
} catch (_) {
return null;
}
}
/**
* @param {Storage|null|undefined} storage
* @param {"light"|"dark"} theme
*/
function writeStored(storage, theme) {
try {
if (!storage || typeof storage.setItem !== "function") {
return;
}
storage.setItem(STORAGE_KEY, theme);
} catch (_) {
/* private mode / quota */
}
}
/**
* @param {MediaQueryList|null|undefined} mql
* @returns {boolean}
*/
function systemPrefersDark(mql) {
return !!(mql && mql.matches);
}
/**
* Read effective theme from a document (data-bs-theme on <html>).
* @param {Document|null|undefined} doc
* @returns {"light"|"dark"}
*/
function currentTheme(doc) {
var el = doc && doc.documentElement;
var v =
el && typeof el.getAttribute === "function"
? el.getAttribute("data-bs-theme")
: null;
return normalizeTheme(v) || "light";
}
/**
* OSM tile URL + Leaflet options for the given UI theme.
* @param {"light"|"dark"|string} theme
* @returns {{ url: string, maxZoom: number, attribution: string, subdomains?: string }}
*/
function osmBasemap(theme) {
return theme === "dark" ? OSM_BASEMAP_DARK : OSM_BASEMAP_LIGHT;
}
/**
* Build a Leaflet OSM (or dark OSM) tile layer for the current UI theme.
* @param {*} L Leaflet global
* @param {"light"|"dark"|string} [theme]
* @returns {*} Leaflet tile layer
*/
function createLeafletOsmLayer(L, theme) {
var cfg = osmBasemap(theme === "dark" ? "dark" : "light");
var opts = {
maxZoom: cfg.maxZoom,
attribution: cfg.attribution,
};
if (cfg.subdomains) {
opts.subdomains = cfg.subdomains;
}
return L.tileLayer(cfg.url, opts);
}
/**
* Notify listeners (maps) that the UI theme changed.
* @param {Document|null|undefined} doc
* @param {"light"|"dark"} theme
*/
function dispatchThemeChange(doc, theme) {
if (!doc || typeof doc.dispatchEvent !== "function") {
return;
}
try {
var Ev =
typeof CustomEvent === "function"
? CustomEvent
: doc.defaultView && doc.defaultView.CustomEvent;
if (typeof Ev !== "function") {
return;
}
doc.dispatchEvent(
new Ev(THEME_CHANGE_EVENT, {
detail: { theme: theme },
bubbles: true,
})
);
} catch (_) {
/* ignore */
}
}
/**
* Apply theme to a document root (html element).
* @param {"light"|"dark"|string} theme
* @param {Element|null|undefined} rootEl
* @returns {"light"|"dark"}
*/
function applyTheme(theme, rootEl) {
var t = theme === "dark" ? "dark" : "light";
if (rootEl && typeof rootEl.setAttribute === "function") {
rootEl.setAttribute("data-bs-theme", t);
var doc = rootEl.ownerDocument || null;
dispatchThemeChange(doc, t);
}
return t;
}
/**
* @param {"light"|"dark"} current
* @returns {"light"|"dark"}
*/
function nextTheme(current) {
return current === "dark" ? "light" : "dark";
}
/**
* Update toggle accessible name / pressed state.
* @param {Element|null|undefined} btn
* @param {"light"|"dark"} theme
*/
function syncToggle(btn, theme) {
if (!btn || typeof btn.setAttribute !== "function") {
return;
}
var dark = theme === "dark";
btn.setAttribute("aria-pressed", dark ? "true" : "false");
btn.setAttribute(
"aria-label",
dark ? "Switch to light mode" : "Switch to dark mode"
);
btn.setAttribute("title", dark ? "Light mode" : "Dark mode");
}
/**
* Read system preference when matchMedia is available.
* @param {Window|null|undefined} win
* @returns {boolean}
*/
function prefersDarkFromWindow(win) {
if (!win || typeof win.matchMedia !== "function") {
return false;
}
try {
return systemPrefersDark(win.matchMedia("(prefers-color-scheme: dark)"));
} catch (_) {
return false;
}
}
/**
* Apply stored/system theme to document.
* @param {Document} doc
* @param {Window} win
* @returns {"light"|"dark"}
*/
function boot(doc, win) {
var storage = win && win.localStorage ? win.localStorage : null;
var theme = resolveTheme(readStored(storage), prefersDarkFromWindow(win));
applyTheme(theme, doc && doc.documentElement);
return theme;
}
/**
* Wire [data-js="theme-toggle"] and keep in sync with system when no stored pref.
* @param {Document} doc
* @param {Window} win
*/
function bind(doc, win) {
if (!doc || !win) {
return;
}
var storage = win.localStorage || null;
var btn = doc.querySelector('[data-js="theme-toggle"]');
var current =
normalizeTheme(
doc.documentElement && doc.documentElement.getAttribute("data-bs-theme")
) || "light";
syncToggle(btn, current);
if (btn && typeof btn.addEventListener === "function") {
btn.addEventListener("click", function () {
var cur =
normalizeTheme(doc.documentElement.getAttribute("data-bs-theme")) ||
"light";
var next = nextTheme(cur);
applyTheme(next, doc.documentElement);
writeStored(storage, next);
syncToggle(btn, next);
});
}
// Follow OS only when the user has not chosen an explicit theme.
if (typeof win.matchMedia === "function") {
try {
var mql = win.matchMedia("(prefers-color-scheme: dark)");
var onChange = function () {
if (normalizeTheme(readStored(storage))) {
return;
}
var theme = resolveTheme(null, systemPrefersDark(mql));
applyTheme(theme, doc.documentElement);
syncToggle(btn, theme);
};
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
} else if (typeof mql.addListener === "function") {
mql.addListener(onChange);
}
} catch (_) {
/* ignore */
}
}
}
var api = {
STORAGE_KEY: STORAGE_KEY,
THEME_CHANGE_EVENT: THEME_CHANGE_EVENT,
OSM_BASEMAP_LIGHT: OSM_BASEMAP_LIGHT,
OSM_BASEMAP_DARK: OSM_BASEMAP_DARK,
normalizeTheme: normalizeTheme,
resolveTheme: resolveTheme,
readStored: readStored,
writeStored: writeStored,
systemPrefersDark: systemPrefersDark,
currentTheme: currentTheme,
osmBasemap: osmBasemap,
createLeafletOsmLayer: createLeafletOsmLayer,
dispatchThemeChange: dispatchThemeChange,
applyTheme: applyTheme,
nextTheme: nextTheme,
syncToggle: syncToggle,
boot: boot,
bind: bind,
};
root.OpenFSDTheme = api;
// Immediate apply when loaded as a classic script in the browser.
if (typeof document !== "undefined" && typeof window !== "undefined") {
boot(document, window);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
bind(document, window);
});
} else {
bind(document, window);
}
}
})(typeof globalThis !== "undefined" ? globalThis : this);

View File

@@ -45,11 +45,11 @@
<p class="apted-flash is-empty" data-js="status" role="status" aria-hidden="true"></p>
<div class="apted-toolbar" role="toolbar" aria-label="Editor tools">
<label class="btn btn-sm btn-outline-dark apted-file-btn">
<label class="btn btn-sm btn-outline-secondary apted-file-btn">
Open .apt
<input type="file" accept=".apt,text/plain" data-js="open-apt" hidden>
</label>
<label class="btn btn-sm btn-outline-dark apted-file-btn">
<label class="btn btn-sm btn-outline-secondary apted-file-btn">
Open .air
<input type="file" accept=".air,text/plain" data-js="open-air" hidden>
</label>
@@ -69,17 +69,17 @@
aria-label="Basemap layer: OSM. Click to switch.">Layer: OSM</button>
<span class="apted-toolbar-sep" aria-hidden="true"></span>
<div class="apted-mode-group" role="group" aria-label="Edit mode" data-js="mode-group">
<button type="button" class="btn btn-sm btn-outline-dark apted-mode-btn is-active"
<button type="button" class="btn btn-sm btn-outline-secondary apted-mode-btn is-active"
data-mode="select" aria-pressed="true" title="Select (1)">1 Select</button>
<button type="button" class="btn btn-sm btn-outline-dark apted-mode-btn"
<button type="button" class="btn btn-sm btn-outline-secondary apted-mode-btn"
data-mode="park" aria-pressed="false" title="Parking (2)">2 Park</button>
<button type="button" class="btn btn-sm btn-outline-dark apted-mode-btn"
<button type="button" class="btn btn-sm btn-outline-secondary apted-mode-btn"
data-mode="taxi" aria-pressed="false" title="Taxiway (3)">3 Taxi</button>
<button type="button" class="btn btn-sm btn-outline-dark apted-mode-btn"
<button type="button" class="btn btn-sm btn-outline-secondary apted-mode-btn"
data-mode="runway" aria-pressed="false" title="Runway (4)">4 Rwy</button>
<button type="button" class="btn btn-sm btn-outline-dark apted-mode-btn"
<button type="button" class="btn btn-sm btn-outline-secondary apted-mode-btn"
data-mode="hold" aria-pressed="false" title="Hold (5)">5 Hold</button>
<button type="button" class="btn btn-sm btn-outline-dark apted-mode-btn"
<button type="button" class="btn btn-sm btn-outline-secondary apted-mode-btn"
data-mode="aircraft" aria-pressed="false" title="Aircraft (6)">6 Acft</button>
</div>
</div>

View File

@@ -3,9 +3,9 @@
{{ define "body" }}
<main class="container-fluid d-flex justify-content-center position-absolute top-50 start-50 translate-middle">
{{ if .User }}
<a class="btn btn-lg btn-outline-dark fw-medium" href="/dashboard">Go to dashboard</a>
<a class="btn btn-lg btn-outline-secondary fw-medium" href="/dashboard">Go to dashboard</a>
{{ else }}
<a class="btn btn-lg btn-outline-dark fw-medium" href="/login">Login</a>
<a class="btn btn-lg btn-outline-secondary fw-medium" href="/login">Login</a>
{{ end }}
</main>
{{ end }}

View File

@@ -4,8 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ if .CSRFToken }}<meta name="csrf-token" content="{{ .CSRFToken }}">{{ end }}
{{/*
Theme boot must run before first paint (classic script, not defer/module)
so data-bs-theme matches localStorage / system preference without a flash.
*/}}
<script src="/static/js/openfsd/theme.js"></script>
<link href="/static/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-SgOJa3DmI69IUzQ2PVdRZhwQ+dy64/BUtbMJw1MZ8t5HZApcHrRKUc4W0kG879m7">
<link href="/static/css/openfsd/theme.css" rel="stylesheet">
<script src="/static/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq"></script>
<script src="/static/js/openfsd/api.js"></script>
@@ -13,10 +19,10 @@
<title>openfsd - {{ template "title" . }}</title>
</head>
<body>
<header class="container-fluid p-2 d-flex justify-content-between align-items-center border-bottom border-1">
<header class="container-fluid p-2 d-flex justify-content-between align-items-center border-bottom border-1 ofs-site-header">
<div class="d-flex align-items-center">
<div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 120.135 120.148" style="enable-background:new 0 0 120.135 120.148;" xml:space="preserve" aria-hidden="true" focusable="false">
<svg class="ofs-brand-mark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 120.135 120.148" style="enable-background:new 0 0 120.135 120.148;" xml:space="preserve" aria-hidden="true" focusable="false">
<g>
<path d="M105.771,120.147c7.933,0,14.363-6.438,14.363-14.359V14.352C120.134,6.422,113.702,0,105.771,0H14.337 C6.408,0,0,6.422,0,14.352v91.436c0,7.922,6.408,14.359,14.337,14.359H105.771z"/>
<path style="fill:#FFFFFF;" d="M14.337,2.435c-6.564,0-11.908,5.347-11.908,11.917v91.436c0,6.58,5.344,11.926,11.908,11.926 h91.434c6.584,0,11.932-5.346,11.932-11.926V14.352c0-6.57-5.348-11.917-11.932-11.917H14.337z"/>
@@ -25,26 +31,35 @@
</svg>
</div>
<div class="ms-2">
<a href="/" class="text-decoration-none text-dark">openfsd</a>
<a href="/" class="text-decoration-none ofs-brand-link">openfsd</a>
</div>
</div>
<nav class="d-flex align-items-center gap-2" aria-label="Primary">
<button type="button"
class="btn btn-sm btn-outline-secondary ofs-theme-toggle"
data-js="theme-toggle"
aria-pressed="false"
aria-label="Switch to dark mode"
title="Dark mode">
<span class="ofs-theme-icon-light" aria-hidden="true"></span>
<span class="ofs-theme-icon-dark" aria-hidden="true"></span>
</button>
{{ if .User }}
<a class="btn btn-sm btn-outline-dark" href="/dashboard">Dashboard</a>
<a class="btn btn-sm btn-outline-secondary" href="/dashboard">Dashboard</a>
{{ if .User.CanEditUsers }}
<a class="btn btn-sm btn-outline-dark" href="/usereditor">Users</a>
<a class="btn btn-sm btn-outline-secondary" href="/usereditor">Users</a>
{{ end }}
{{ if .User.CanEditConfig }}
<a class="btn btn-sm btn-outline-dark" href="/configeditor">Config</a>
<a class="btn btn-sm btn-outline-dark" href="/sweatbox">Sweatbox</a>
<a class="btn btn-sm btn-outline-dark" href="/airport-editor">Airport Editor</a>
<a class="btn btn-sm btn-outline-secondary" href="/configeditor">Config</a>
<a class="btn btn-sm btn-outline-secondary" href="/sweatbox">Sweatbox</a>
<a class="btn btn-sm btn-outline-secondary" href="/airport-editor">Airport Editor</a>
{{ end }}
<form method="post" action="/logout" class="d-inline m-0">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Log out</button>
</form>
{{ else }}
<a class="btn btn-sm btn-outline-dark" href="/login">Login</a>
<a class="btn btn-sm btn-outline-secondary" href="/login">Login</a>
{{ end }}
</nav>
</header>

View File

@@ -2,7 +2,7 @@
{{ define "body" }}
<main class="container-fluid d-flex justify-content-center position-absolute top-50 start-50 translate-middle">
<div class="p-4 rounded border border-dark-subtle border-1" style="min-width: 20rem; max-width: 24rem; width: 100%;">
<div class="p-4 rounded border border-1 ofs-login-card" style="min-width: 20rem; max-width: 24rem; width: 100%;">
<h1 class="h4 mb-3">Log in</h1>
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
@@ -31,7 +31,7 @@
name="remember_me" value="on"{{ if .RememberMe }} checked{{ end }}>
<label class="form-check-label" for="login-form-remember-me">Remember me</label>
</div>
<button type="submit" class="btn btn-outline-dark">Log in</button>
<button type="submit" class="btn btn-outline-secondary">Log in</button>
</form>
</div>
</main>

View File

@@ -22,7 +22,7 @@
<div class="sbx-titlebar">
<h1>Sweatbox</h1>
<p class="sbx-hint">Control panel · forms work without JS · <a href="/sweatbox">Refresh</a> reloads snapshot · <a href="/sweatbox/manual" target="_blank" rel="noopener noreferrer">Manual</a></p>
<p class="sbx-hint"><a href="/sweatbox/manual" target="_blank" rel="noopener noreferrer">User Manual</a></p>
</div>
{{ if .FlashSuccess }}

View File

@@ -16,7 +16,6 @@
<main class="usr container-fluid">
<div class="usr-titlebar">
<h1>Users</h1>
<p class="usr-hint">Directory · create and edit in the rail · forms work without JS</p>
<span class="usr-count" aria-live="polite">{{ .Dir.Total }} total</span>
</div>

View File

@@ -21,13 +21,14 @@ fi
echo "check-webjs: node $(node --version)"
# Portable: expand test files without bash mapfile/globstar.
# Covers airport-editor/* and top-level modules (e.g. theme.test.js).
TESTS=()
while IFS= read -r f; do
TESTS+=("$f")
done < <(find ./airport-editor -name '*.test.js' | LC_ALL=C sort)
done < <(find . -name '*.test.js' | LC_ALL=C sort)
if [[ ${#TESTS[@]} -eq 0 ]]; then
echo "check-webjs: no tests found under webjs/airport-editor" >&2
echo "check-webjs: no tests found under webjs/" >&2
exit 1
fi

View File

@@ -20,8 +20,11 @@ import {
escapeHtml,
OSM_TILE_URL,
OSM_ATTRIBUTION,
OSM_DARK_TILE_URL,
OSM_DARK_ATTRIBUTION,
ESRI_TILE_URL,
ESRI_ATTRIBUTION,
currentUiTheme,
VERTEX_HANDLE_PX,
VERTEX_HANDLE_RADIUS,
VERTEX_HIT_PX,
@@ -181,11 +184,39 @@ test('basemap attribution strings are non-empty', () => {
assert.ok(OSM_TILE_URL.includes('openstreetmap'));
assert.ok(OSM_ATTRIBUTION.length > 10);
assert.ok(OSM_ATTRIBUTION.toLowerCase().includes('openstreetmap'));
assert.ok(OSM_DARK_TILE_URL.includes('cartocdn') || OSM_DARK_TILE_URL.includes('dark'));
assert.ok(OSM_DARK_ATTRIBUTION.toLowerCase().includes('openstreetmap'));
assert.ok(OSM_DARK_ATTRIBUTION.toLowerCase().includes('carto'));
assert.ok(ESRI_TILE_URL.includes('arcgisonline'));
assert.ok(ESRI_ATTRIBUTION.length > 10);
assert.ok(ESRI_ATTRIBUTION.toLowerCase().includes('esri'));
});
test('currentUiTheme falls back without OpenFSDTheme', () => {
// No document / no OpenFSDTheme in Node → light
assert.equal(currentUiTheme(null), 'light');
assert.equal(
currentUiTheme({
documentElement: {
getAttribute(name) {
return name === 'data-bs-theme' ? 'dark' : null;
},
},
}),
'dark',
);
assert.equal(
currentUiTheme({
documentElement: {
getAttribute() {
return 'light';
},
},
}),
'light',
);
});
test('escapeHtml neutralizes markup in freeform AIR-like strings', () => {
assert.equal(escapeHtml(null), '');
assert.equal(escapeHtml('plain'), 'plain');

View File

@@ -6,7 +6,7 @@
"node": ">=20"
},
"scripts": {
"test": "node --test ./airport-editor/*.test.js",
"test:coverage": "node --experimental-test-coverage --test-coverage-include='../internal/web/static/js/openfsd/airport-editor/**' --test ./airport-editor/*.test.js"
"test": "node --test ./airport-editor/*.test.js ./theme.test.js",
"test:coverage": "node --experimental-test-coverage --test-coverage-include='../internal/web/static/js/openfsd/airport-editor/**' --test-coverage-include='../internal/web/static/js/openfsd/theme.js' --test ./airport-editor/*.test.js ./theme.test.js"
}
}

262
webjs/theme.test.js Normal file
View File

@@ -0,0 +1,262 @@
/**
* Unit tests for openfsd theme.js pure helpers (classic script → OpenFSDTheme).
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createContext, runInContext } from "node:vm";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
const __dirname = dirname(fileURLToPath(import.meta.url));
const themePath = join(
__dirname,
"../internal/web/static/js/openfsd/theme.js"
);
const themeSource = readFileSync(themePath, "utf8");
/**
* Load theme.js into an isolated context without auto-binding a toggle.
* Boot still runs against the provided documentElement.
*/
function loadTheme(opts = {}) {
const attrs = new Map();
const documentElement = {
setAttribute(name, value) {
attrs.set(name, String(value));
},
getAttribute(name) {
return attrs.has(name) ? attrs.get(name) : null;
},
};
const store = new Map(opts.storeEntries || []);
const localStorage = {
getItem(k) {
return store.has(k) ? store.get(k) : null;
},
setItem(k, v) {
store.set(k, String(v));
},
};
const prefersDark = !!opts.prefersDark;
const doc = {
documentElement,
readyState: "complete",
querySelector() {
return opts.toggleBtn || null;
},
addEventListener() {},
};
const win = {
localStorage,
matchMedia() {
return {
matches: prefersDark,
addEventListener() {},
addListener() {},
};
},
};
const sandbox = { globalThis: null, document: doc, window: win };
sandbox.globalThis = sandbox;
runInContext(themeSource, createContext(sandbox));
return {
api: sandbox.OpenFSDTheme,
attrs,
store,
documentElement,
localStorage,
doc,
win,
};
}
describe("OpenFSDTheme", () => {
it("exports the public API", () => {
const { api } = loadTheme();
assert.equal(typeof api.resolveTheme, "function");
assert.equal(typeof api.applyTheme, "function");
assert.equal(typeof api.nextTheme, "function");
assert.equal(api.STORAGE_KEY, "openfsd-theme");
});
it("resolveTheme prefers stored light/dark over system", () => {
const { api } = loadTheme();
assert.equal(api.resolveTheme("dark", false), "dark");
assert.equal(api.resolveTheme("light", true), "light");
assert.equal(api.resolveTheme(null, true), "dark");
assert.equal(api.resolveTheme(null, false), "light");
assert.equal(api.resolveTheme("bogus", true), "dark");
assert.equal(api.resolveTheme("", false), "light");
});
it("normalizeTheme accepts only light|dark", () => {
const { api } = loadTheme();
assert.equal(api.normalizeTheme("dark"), "dark");
assert.equal(api.normalizeTheme("light"), "light");
assert.equal(api.normalizeTheme("auto"), null);
assert.equal(api.normalizeTheme(null), null);
});
it("nextTheme toggles", () => {
const { api } = loadTheme();
assert.equal(api.nextTheme("light"), "dark");
assert.equal(api.nextTheme("dark"), "light");
});
it("applyTheme sets data-bs-theme on the root", () => {
const { api, attrs, documentElement } = loadTheme();
assert.equal(api.applyTheme("dark", documentElement), "dark");
assert.equal(attrs.get("data-bs-theme"), "dark");
assert.equal(api.applyTheme("nope", documentElement), "light");
assert.equal(attrs.get("data-bs-theme"), "light");
});
it("readStored/writeStored round-trip and tolerate missing storage", () => {
const { api, localStorage } = loadTheme();
assert.equal(api.readStored(null), null);
assert.equal(api.readStored(localStorage), null);
api.writeStored(localStorage, "dark");
assert.equal(api.readStored(localStorage), "dark");
assert.equal(api.readStored({}), null);
api.writeStored(null, "light"); // no throw
});
it("systemPrefersDark mirrors MediaQueryList.matches", () => {
const { api } = loadTheme();
assert.equal(api.systemPrefersDark(null), false);
assert.equal(api.systemPrefersDark({ matches: true }), true);
assert.equal(api.systemPrefersDark({ matches: false }), false);
});
it("syncToggle updates aria-label and pressed", () => {
const { api } = loadTheme();
const btnAttrs = new Map();
const btn = {
setAttribute(k, v) {
btnAttrs.set(k, String(v));
},
};
api.syncToggle(btn, "dark");
assert.equal(btnAttrs.get("aria-pressed"), "true");
assert.match(btnAttrs.get("aria-label"), /light/i);
api.syncToggle(btn, "light");
assert.equal(btnAttrs.get("aria-pressed"), "false");
assert.match(btnAttrs.get("aria-label"), /dark/i);
});
it("boot applies stored preference on script load", () => {
const { attrs } = loadTheme({
storeEntries: [["openfsd-theme", "dark"]],
});
assert.equal(attrs.get("data-bs-theme"), "dark");
});
it("boot falls back to system preference when unset", () => {
const { attrs } = loadTheme({ prefersDark: true });
assert.equal(attrs.get("data-bs-theme"), "dark");
});
it("boot defaults to light without preference", () => {
const { attrs } = loadTheme({ prefersDark: false });
assert.equal(attrs.get("data-bs-theme"), "light");
});
it("osmBasemap returns light OSM and dark CARTO configs", () => {
const { api } = loadTheme();
const light = api.osmBasemap("light");
const dark = api.osmBasemap("dark");
assert.ok(light.url.includes("openstreetmap.org"));
assert.match(light.attribution, /OpenStreetMap/i);
assert.ok(dark.url.includes("cartocdn") || dark.url.includes("dark"));
assert.match(dark.attribution, /OpenStreetMap/i);
assert.match(dark.attribution, /CARTO/i);
assert.equal(dark.subdomains, "abcd");
});
it("createLeafletOsmLayer passes theme-specific url to L.tileLayer", () => {
const { api } = loadTheme();
const calls = [];
const L = {
tileLayer(url, opts) {
calls.push({ url, opts });
return { url, opts };
},
};
api.createLeafletOsmLayer(L, "light");
api.createLeafletOsmLayer(L, "dark");
assert.equal(calls.length, 2);
assert.ok(calls[0].url.includes("openstreetmap.org"));
assert.equal(calls[0].opts.maxZoom, 19);
assert.ok(calls[1].url.includes("cartocdn") || calls[1].url.includes("dark"));
assert.equal(calls[1].opts.subdomains, "abcd");
});
it("applyTheme dispatches openfsd:themechange", () => {
const events = [];
const attrs = new Map();
const documentElement = {
setAttribute(name, value) {
attrs.set(name, String(value));
},
getAttribute(name) {
return attrs.has(name) ? attrs.get(name) : null;
},
ownerDocument: null,
};
const doc = {
documentElement,
readyState: "complete",
querySelector() {
return null;
},
addEventListener() {},
dispatchEvent(ev) {
events.push(ev);
return true;
},
};
documentElement.ownerDocument = doc;
// Minimal CustomEvent for vm
function CustomEvent(type, init) {
this.type = type;
this.detail = init && init.detail;
this.bubbles = !!(init && init.bubbles);
}
const store = new Map();
const win = {
localStorage: {
getItem(k) {
return store.has(k) ? store.get(k) : null;
},
setItem(k, v) {
store.set(k, String(v));
},
},
matchMedia() {
return { matches: false, addEventListener() {}, addListener() {} };
},
CustomEvent,
};
const sandbox = {
globalThis: null,
document: doc,
window: win,
CustomEvent,
};
sandbox.globalThis = sandbox;
runInContext(themeSource, createContext(sandbox));
// boot already applied light once
const afterBoot = events.length;
sandbox.OpenFSDTheme.applyTheme("dark", documentElement);
assert.ok(events.length > afterBoot);
const last = events[events.length - 1];
assert.equal(last.type, "openfsd:themechange");
assert.equal(last.detail.theme, "dark");
assert.equal(attrs.get("data-bs-theme"), "dark");
});
});