librelad 9a92805bdb feat(ui): Beginner/Advanced experience level + linked dev mode + setup-wizard step
Adds the install-time Beginner/Advanced choice the user described, with
the linked dev-mode escape hatch and global body-class machinery that
any surface can hang advanced/dev-only DOM off.

Three-tier mental model, two flags in the data model:

  Beginner            default. nothing extra shown.
  Advanced            .lp-advanced DOM revealed; advanced wizard steps shown
  Adv+Dev             .lp-dev DOM also revealed; dev-only fields visible

Linking rule (enforced inside LpUi):
  - enabling dev auto-enables advanced (dev w/o advanced is incoherent)
  - disabling advanced auto-disables dev

Wire shape:
  CFG_INSTALL_LEVEL                  beginner | advanced (general_basic)
  CFG_DEV_MODE                       existing, unchanged behaviour
  window.LpUi.{advanced,dev}         {get(), set(), apply()}
  localStorage keys                  lp.ui.advanced, lp.ui.dev, lp.ui.seeded
  body classes                       lp-ui--advanced, lp-ui--dev
  events                             lp-ui-advanced-changed, lp-ui-dev-changed
  global CSS gates                   body:not(.lp-ui--advanced) .lp-advanced { hide }
                                     body:not(.lp-ui--dev) .lp-dev { hide }

Setup wizard:
  - New step 1 "Choose your experience" with Beginner/Advanced cards.
    Beginner is preselected so race-through gets the safe default.
  - Picking a level updates totalSteps live (4 for beginner, 5 for
    advanced) so the progress bar reflects the choice.
  - Metrics step (Prometheus + Grafana) is gated to Advanced — beginner
    never sees it, never gets asked, never installs them by accident.
  - Submit payload now carries install_level; setup-routes.js validates
    it against the enum (beginner|advanced).
  - scripts/setup/setup_apply.sh writes it to CFG_INSTALL_LEVEL via
    updateConfigOption.
  - On submit, LpUi.advanced.set is called immediately so the next
    surface (running-tasks page) is already in the right mode — no
    refresh needed.

WebUI bootstrap:
  - js/utils/lp-ui.js loads first thing in index.html (before any other
    bootstrap) so body.lp-ui--advanced is applied pre-paint — no FOUC
    of advanced content on a fresh tab.
  - On first run, seeds lp.ui.advanced from CFG_INSTALL_LEVEL.
    Subsequent loads honour the user's per-browser override.
  - Mirrors CFG_DEV_MODE → lp.ui.dev on the seed pass.

Dev-mode unlock:
  - Existing 10-click LibrePortal-logo easter egg unchanged.
  - NEW: same 10-click unlock on the Advanced toggle (in services-manager).
    Reuses the countdown-toast pattern; on the 10th click delegates to
    the topbar's _setDevMode so there's one canonical setter and the
    config_update task path stays singular.
  - TopbarComponent now exposes its instance as window.topbar so the
    toggle's tap handler can reach _setDevMode.
  - topbar._setDevMode also calls LpUi.dev.set(enabled) so the body
    class flips immediately (no reload needed to see dev-only DOM).

Convention rolled out:
  - Services tab's .service-rich panel was already gated on
    body.lp-ui--advanced.
  - .lp-advanced / .lp-dev are now first-class hide classes any
    component can tag DOM with — see style.css globals.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 23:31:50 +01:00

125 lines
5.4 KiB
JavaScript

// window.LpUi — global UI-mode flags shared across the WebUI.
//
// Two orthogonal axes:
// advanced — show extra-technical detail (mounts, limits, internals,
// raw IDs, advanced config fields). Off by default
// ("Beginner"); user can flip on from any surface that
// exposes the Advanced toggle.
// dev — show developer-only fields and surfaces (the **DEV**
// config fields, Installation Mode picker, etc.). Locked
// behind the 10-tap unlock easter egg (LibrePortal logo
// OR the Advanced toggle). Auto-enables when on a git/
// local install (the existing topbar autodetect path).
//
// Linking rule: dev IMPLIES advanced.
// - Enabling dev auto-enables advanced (you can't see dev fields if
// the advanced layer is hidden).
// - Disabling advanced auto-disables dev (dev-without-advanced is
// an incoherent state — clean it up).
//
// Wire shape:
// localStorage keys lp.ui.advanced ('0' | '1')
// lp.ui.dev ('0' | '1') — UI-side mirror of CFG_DEV_MODE
// body classes lp-ui--advanced
// lp-ui--dev
// events window 'lp-ui-advanced-changed' { advanced }
// window 'lp-ui-dev-changed' { dev }
//
// Loaded eagerly (NOT lazy) via a <script> tag in index.html so the body
// class is applied before any page renders — no flash of unhidden content
// on a fresh load. Components that want to react to the flag should
// listen for the change events rather than poll.
(function setupLpUi() {
if (window.LpUi && window.LpUi.advanced && window.LpUi.dev) return;
const ADV_KEY = 'lp.ui.advanced';
const DEV_KEY = 'lp.ui.dev';
const SEED_KEY = 'lp.ui.seeded'; // marks that we've consulted CFG_INSTALL_LEVEL once
function safeGet(k) {
try { return localStorage.getItem(k); } catch { return null; }
}
function safeSet(k, v) {
try { localStorage.setItem(k, v); } catch { /* private mode */ }
}
function applyBodyClasses() {
if (!document.body) return;
document.body.classList.toggle('lp-ui--advanced', advanced.get());
document.body.classList.toggle('lp-ui--dev', dev.get());
}
const advanced = {
get() { return safeGet(ADV_KEY) === '1'; },
set(on) {
const v = !!on;
const before = this.get();
if (v === before) return;
safeSet(ADV_KEY, v ? '1' : '0');
document.body && document.body.classList.toggle('lp-ui--advanced', v);
// Dev implies advanced — turning advanced off also clears dev.
if (!v && dev.get()) dev.set(false);
window.dispatchEvent(new CustomEvent('lp-ui-advanced-changed', { detail: { advanced: v } }));
},
apply() { document.body && document.body.classList.toggle('lp-ui--advanced', this.get()); },
};
const dev = {
get() { return safeGet(DEV_KEY) === '1'; },
set(on) {
const v = !!on;
const before = this.get();
if (v === before) return;
safeSet(DEV_KEY, v ? '1' : '0');
document.body && document.body.classList.toggle('lp-ui--dev', v);
// Dev implies advanced — turning dev on lifts advanced too.
if (v && !advanced.get()) advanced.set(true);
window.dispatchEvent(new CustomEvent('lp-ui-dev-changed', { detail: { dev: v } }));
},
apply() { document.body && document.body.classList.toggle('lp-ui--dev', this.get()); },
};
// Seed lp.ui.advanced from CFG_INSTALL_LEVEL the first time we run.
// After that, the local flip is authoritative — we don't want the
// user's choice in the WebUI overridden every reload by the install-
// time default.
async function seedFromConfigIfNeeded() {
if (safeGet(SEED_KEY) === '1') return;
try {
const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`);
if (!r.ok) { safeSet(SEED_KEY, '1'); return; }
const data = await r.json();
const level = (data?.config?.CFG_INSTALL_LEVEL?.value || 'beginner').toLowerCase();
const wantAdvanced = (level === 'advanced');
// Only seed if the user hasn't already set a value.
if (safeGet(ADV_KEY) === null) {
safeSet(ADV_KEY, wantAdvanced ? '1' : '0');
}
// Mirror CFG_DEV_MODE into the body class on first load so dev
// gating works before the topbar autodetect runs. The topbar
// still owns toggling CFG_DEV_MODE itself; we just read it.
const devOn = (data?.config?.CFG_DEV_MODE?.value === 'true' || data?.config?.CFG_DEV_MODE?.value === true);
safeSet(DEV_KEY, devOn ? '1' : '0');
safeSet(SEED_KEY, '1');
applyBodyClasses();
} catch {
safeSet(SEED_KEY, '1');
}
}
window.LpUi = { advanced, dev };
// Apply ASAP — once body exists drop the classes in. If the script
// loads before body, defer; otherwise do it immediately.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
applyBodyClasses();
seedFromConfigIfNeeded();
}, { once: true });
} else {
applyBodyClasses();
seedFromConfigIfNeeded();
}
})();