diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx
index e2ae34c..a690933 100644
--- a/src/features/packets/ObservationCard.tsx
+++ b/src/features/packets/ObservationCard.tsx
@@ -15,7 +15,7 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
selected
? "border-l-secondary bg-secondary/5"
: "border-l-primary"
- } ${onClick ? "cursor-pointer hover:bg-white/3" : ""}`}
+ } ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
onClick={onClick}
>
diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx
index 05ccc54..71eab28 100644
--- a/src/features/stats/ObserverTab.tsx
+++ b/src/features/stats/ObserverTab.tsx
@@ -82,7 +82,7 @@ function ObserverList({
type="button"
onClick={() => onSelect(r.id)}
className={`relative overflow-hidden rounded border-l-2 px-2.5 py-1.5 text-left transition-colors cursor-pointer ${
- active ? "border-primary bg-primary/10" : "border-transparent hover:bg-white/3"
+ active ? "border-primary bg-primary/10" : "border-transparent hover:bg-text-normal/3"
}`}
>
{r.count != null && (
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 7c8f15d..07b780b 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -15,3 +15,35 @@ export const WS_RECONNECT_JITTER = 0.25;
// app tab names, in display order; the ?tab URL param is validated against this list
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const;
+
+// Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser;
+// the Docker entrypoint sed-substitutes each sentinel at container start — see .build/).
+
+// Split a comma-separated env value into trimmed, non-empty entries. An unset var or an
+// un-substituted "__VITE_..__" sentinel yields a harmless single entry that matches nothing.
+export function parseEnvList(raw: string | undefined): string[] {
+ return (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
+}
+
+// Tabs left after removing the disabled ones (case-insensitive), keeping display order.
+export function filterEnabledTabs(tabs: readonly string[], disabledRaw: string | undefined): string[] {
+ const disabled = new Set(parseEnvList(disabledRaw).map((t) => t.toLowerCase()));
+ return tabs.filter((t) => !disabled.has(t.toLowerCase()));
+}
+
+// A theme shows in the picker if it isn't hidden, or if its id is in the enabled allowlist.
+export function isThemeVisible(theme: { id: string; hidden?: boolean }, enabledIds: Set): boolean {
+ return !theme.hidden || enabledIds.has(theme.id.toLowerCase());
+}
+
+const enabledTabs = filterEnabledTabs(TABS, import.meta.env.VITE_DISABLED_TABS);
+// Floor: an all-disabled misconfig would otherwise leave a blank app.
+export const ENABLED_TABS = enabledTabs.length > 0 ? enabledTabs : [...TABS];
+
+export const ENABLED_THEME_IDS = new Set(
+ parseEnvList(import.meta.env.VITE_ENABLED_THEMES).map((s) => s.toLowerCase()),
+);
+
+// "||" (not "??") so an empty sed substitution falls back to the default too.
+export const APP_NAME = import.meta.env.VITE_APP_NAME || "BEACON";
+export const GITHUB_URL = "https://github.com/MeshCore-Beacon";
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index 28f54a9..46bd726 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -4,6 +4,8 @@ export interface Theme {
id: string;
name: string;
vars: Record;
+ // hidden themes are omitted from the picker unless enabled via VITE_ENABLED_THEMES
+ hidden?: boolean;
}
export const DEFAULT_THEME_ID = "neutral-blue";
diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts
new file mode 100644
index 0000000..f2cca85
--- /dev/null
+++ b/tests/lib/constants.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from "vitest";
+import { TABS, parseEnvList, filterEnabledTabs, isThemeVisible } from "../../src/lib/constants";
+
+describe("parseEnvList", () => {
+ it("returns [] for undefined or empty", () => {
+ expect(parseEnvList(undefined)).toEqual([]);
+ expect(parseEnvList("")).toEqual([]);
+ });
+
+ it("splits, trims, and drops empty entries", () => {
+ expect(parseEnvList("Map,Nodes")).toEqual(["Map", "Nodes"]);
+ expect(parseEnvList(" Map , Nodes ,, ")).toEqual(["Map", "Nodes"]);
+ });
+});
+
+describe("filterEnabledTabs", () => {
+ it("returns all tabs (order preserved) when nothing is disabled", () => {
+ expect(filterEnabledTabs(TABS, undefined)).toEqual([...TABS]);
+ expect(filterEnabledTabs(TABS, "")).toEqual([...TABS]);
+ });
+
+ it("excludes named tabs case-insensitively", () => {
+ expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([
+ "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics",
+ ]);
+ expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([
+ "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics",
+ ]);
+ });
+
+ it("ignores unknown tab names", () => {
+ expect(filterEnabledTabs(TABS, "bogus")).toEqual([...TABS]);
+ });
+
+ it("can disable every tab", () => {
+ expect(filterEnabledTabs(TABS, TABS.join(","))).toEqual([]);
+ });
+});
+
+describe("isThemeVisible", () => {
+ const empty = new Set();
+
+ it("always shows a non-hidden theme", () => {
+ expect(isThemeVisible({ id: "neutral-blue" }, empty)).toBe(true);
+ expect(isThemeVisible({ id: "neutral-blue", hidden: false }, empty)).toBe(true);
+ });
+
+ it("hides a hidden theme unless it is enabled", () => {
+ expect(isThemeVisible({ id: "meshmapper_dark", hidden: true }, empty)).toBe(false);
+ expect(isThemeVisible({ id: "meshmapper_dark", hidden: true }, new Set(["meshmapper_dark"]))).toBe(true);
+ });
+
+ it("matches enabled ids case-insensitively", () => {
+ expect(isThemeVisible({ id: "MeshMapper_Dark", hidden: true }, new Set(["meshmapper_dark"]))).toBe(true);
+ });
+});