diff --git a/containers/libreportal/frontend/css/setup-wizard.css b/containers/libreportal/frontend/css/setup-wizard.css
index 993c5d6..ec9fb96 100755
--- a/containers/libreportal/frontend/css/setup-wizard.css
+++ b/containers/libreportal/frontend/css/setup-wizard.css
@@ -1116,3 +1116,34 @@ body.setup-wizard-open {
color: rgba(var(--text-rgb), 0.65);
line-height: 1.45;
}
+
+/* Dev-mode easter egg strip — revealed by 10 taps on the Advanced card
+ (see setup-wizard.js). [hidden] keeps it out of layout (no phantom gap
+ from the parent flex column) until JS clears the attribute, at which
+ point the entrance keyframes play. */
+.setup-dev-strip {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.16) 0%, rgba(var(--accent-rgb), 0.05) 100%);
+ border: 1px solid rgba(var(--accent-rgb), 0.5);
+ box-shadow: 0 6px 22px rgba(var(--accent-rgb), 0.16);
+ overflow: hidden;
+ animation: swDevStripIn .4s cubic-bezier(.2, .7, .3, 1) both;
+}
+/* Author display:flex above outranks the UA [hidden] rule, so re-hide. */
+.setup-dev-strip[hidden] { display: none; }
+.setup-dev-strip-icon { font-size: 1.5rem; line-height: 1; }
+.setup-dev-strip-text { display: flex; flex-direction: column; gap: 2px; }
+.setup-dev-strip-text strong { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
+.setup-dev-strip-text span { font-size: 0.8rem; color: rgba(var(--text-rgb), 0.65); }
+
+@keyframes swDevStripIn {
+ from { opacity: 0; transform: translateY(-6px); max-height: 0; padding-top: 0; padding-bottom: 0; }
+ to { opacity: 1; transform: translateY(0); max-height: 160px; padding-top: 12px; padding-bottom: 12px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .setup-dev-strip { animation-duration: .01ms; }
+}
diff --git a/containers/libreportal/frontend/js/system/setup-wizard.js b/containers/libreportal/frontend/js/system/setup-wizard.js
index def089d..cbc7716 100755
--- a/containers/libreportal/frontend/js/system/setup-wizard.js
+++ b/containers/libreportal/frontend/js/system/setup-wizard.js
@@ -23,6 +23,7 @@ class SetupWizard {
this.installLevel = 'beginner';
this.totalSteps = this._effectiveTotalSteps();
this.domainCount = 0; // tracked dynamically as the user adds rows
+ this.devMode = false; // unlocked by the Advanced-card 10-tap easter egg
}
_effectiveTotalSteps() {
@@ -118,6 +119,13 @@ class SetupWizard {
+
+
🛠️
+
+ Dev mode activated
+ Developer fields will be unlocked after install.
+
+
@@ -270,6 +278,34 @@ class SetupWizard {
});
});
+ // Easter egg: 10 taps on the Advanced card toggles Developer mode and
+ // reveals a strip beneath the cards — same 10-tap pattern as the topbar
+ // logo and services-manager unlocks (CFG_DEV_MODE), persisted on install
+ // via the setup payload's dev_mode field. Counting the radio's click
+ // (not the label's) avoids the label→input double-fire: the radio is
+ // pointer-events:none, so each tap reaches it exactly once via the label.
+ // A 3s idle gap resets the streak.
+ const advRadio = this.container.querySelector('input[name="sw-level"][value="advanced"]');
+ const devStrip = this.container.querySelector('#sw-dev-strip');
+ if (advRadio && devStrip) {
+ const TARGET = 10;
+ let taps = 0;
+ let resetTimer = null;
+ advRadio.addEventListener('click', () => {
+ taps++;
+ if (resetTimer) clearTimeout(resetTimer);
+ resetTimer = setTimeout(() => { taps = 0; }, 3000);
+ if (taps >= TARGET) {
+ taps = 0;
+ clearTimeout(resetTimer);
+ this.devMode = !this.devMode;
+ // Toggling [hidden] (display:none → flex) replays the entrance
+ // animation each time it's revealed.
+ devStrip.hidden = !this.devMode;
+ }
+ });
+ }
+
$('#setup-form').addEventListener('submit', (e) => {
e.preventDefault();
console.log('[setup] form submit fired');
@@ -676,6 +712,7 @@ class SetupWizard {
install_name: $('#sw-name').value.trim(),
timezone: $('#sw-timezone').value,
install_level: this.installLevel,
+ dev_mode: this.devMode,
domains,
apps,
appOptions,
@@ -692,6 +729,11 @@ class SetupWizard {
try { localStorage.setItem('lp.ui.seeded', '1'); } catch {}
} catch {}
}
+ // Same in-process mirror for the dev-mode easter egg (LpUi.dev enabling
+ // dev also enables advanced); the bash applier persists CFG_DEV_MODE.
+ if (this.devMode && window.LpUi?.dev) {
+ try { window.LpUi.dev.set(true); } catch {}
+ }
const submitBtn = $('#sw-submit');
submitBtn.disabled = true;
diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh
index f7e6d04..87b15ac 100644
--- a/scripts/setup/setup_apply.sh
+++ b/scripts/setup/setup_apply.sh
@@ -21,6 +21,7 @@ setupApplyConfig()
local install_name=$(echo "$payload" | jq -r '.install_name // empty')
local timezone=$(echo "$payload" | jq -r '.timezone // empty')
local install_level=$(echo "$payload" | jq -r '.install_level // empty')
+ local dev_mode=$(echo "$payload" | jq -r '.dev_mode // empty')
local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
local domains_json=$(echo "$payload" | jq -c '.domains // []')
@@ -43,6 +44,13 @@ setupApplyConfig()
isSuccessful "Experience level set to '$install_level'"
fi
+ # Developer mode — opt-in via the wizard's Advanced-card easter egg (10
+ # taps). Unlocks the **DEV**-marked CFG_* fields across the WebUI.
+ if [[ "$dev_mode" == "true" ]]; then
+ updateConfigOption "CFG_DEV_MODE" "true"
+ isSuccessful "Developer mode enabled"
+ fi
+
local domains_count=$(echo "$domains_json" | jq -r 'length')
if [[ "$domains_count" -gt 0 ]]; then
local i=0