mirror of
https://github.com/renorris/openfsd
synced 2026-08-13 04:55:42 +08:00
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:
1171
docs/design/sweatbox-twr-parity-p2.md
Normal file
1171
docs/design/sweatbox-twr-parity-p2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
124
internal/web/static/css/openfsd/theme.css
Normal file
124
internal/web/static/css/openfsd/theme.css
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
'© <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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> ' +
|
||||
'© <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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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:
|
||||
'© <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],
|
||||
|
||||
326
internal/web/static/js/openfsd/theme.js
Normal file
326
internal/web/static/js/openfsd/theme.js
Normal 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:
|
||||
'© <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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> ' +
|
||||
'© <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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
262
webjs/theme.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user