From 4c3ec2ff6657141b1568a85998d17430bd8507a1 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 24 Jun 2026 17:02:16 -0500 Subject: [PATCH] feat(plugins): full-screen (immersive) plugin screens via manifest opt-in (#590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the v3 topbar — embedded in the shell they get cut off at the bottom with excess padding up top. Add an opt-in top-level `"fullscreen": true` plugin.json field, surfaced as the `fullscreen` boolean on /api/plugins (mirrors the settings_category plumbing in plugins/__init__.py). When a fullscreen plugin's screen is active, static/v3/ shell.js toggles `html.fb-immersive` from syncActive() so it tracks every navigation incl. deep-link; static/v3/v3.css then hides the topbar, collapses the sidebar to a functional icon rail (kept reachable — Escape is bound only on player/settings scopes, so a fully hidden sidebar would trap the user), and lets the active plugin screen fill #v3-main. Mirrors the existing ss-follower-pre chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Test: tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest Claude-Session: https://claude.ai/code/session_01BmWopMsRjdZyD6RwmZAQBv Signed-off-by: ChrisBeWithYou Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- CHANGELOG.md | 1 + plugins/__init__.py | 13 +++++++++++++ static/v3/shell.js | 26 ++++++++++++++++++++++++-- static/v3/v3.css | 36 ++++++++++++++++++++++++++++++++++++ tests/test_plugins.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26234a7..2ac95ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`. - **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo. - **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable). - **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf). diff --git a/plugins/__init__.py b/plugins/__init__.py index a5cef3c..a4fd348 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -1350,6 +1350,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N _icon = "assets/thumb.png" except OSError: _icon = None + # Immersive (full-screen) screen opt-in. A plugin that declares a + # top-level `"fullscreen": true` gets the whole content area when its + # screen is active: the v3 shell hides the topbar and collapses the + # sidebar to an icon rail (see static/v3/shell.js + v3.css). For + # DAW-style plugin UIs that need the viewport, not a scrolling content + # page. Strict `is True` so a stray truthy value can't silently opt in. + _fullscreen = manifest.get("fullscreen") is True return { "id": plugin_id, "name": manifest.get("name", plugin_id), @@ -1368,6 +1375,9 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N "has_script": bool(manifest.get("script")), "has_settings": bool(manifest.get("settings")), "settings_category": _settings_category, + # Drives the v3 shell's immersive (full-screen) mode for this + # plugin's screen. False unless the manifest declares it explicitly. + "fullscreen": _fullscreen, "has_tour": _is_valid_tour_manifest(manifest.get("tour")), # `styles` is an optional relpath (under the plugin's assets/) to a # compiled, preflight-off stylesheet the frontend injects as a @@ -2080,6 +2090,8 @@ def register_plugin_api(app: FastAPI): "has_screen": p["has_screen"], "has_script": p["has_script"], "has_settings": p["has_settings"], + # v3 immersive screen opt-in (full-screen plugin UI). + "fullscreen": p.get("fullscreen", False), # Settings-tab placement; None when the manifest's `settings` # is absent, a bare string, or omits `category`. "settings_category": p.get("settings_category"), @@ -2132,6 +2144,7 @@ def register_plugin_api(app: FastAPI): "has_script": e.get("has_script", False), "has_settings": e.get("has_settings", False), "settings_category": e.get("settings_category"), + "fullscreen": e.get("fullscreen", False), "has_tour": e.get("has_tour", False), "has_styles": e.get("has_styles", False), "styles": e.get("styles"), diff --git a/static/v3/shell.js b/static/v3/shell.js index 252ea19..1239f48 100644 --- a/static/v3/shell.js +++ b/static/v3/shell.js @@ -17,6 +17,13 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + // Plugin ids whose manifest declared `"fullscreen": true`. Populated from + // /api/plugins in renderPromotedNav(); read by syncActive() to toggle the + // immersive (chrome-collapsed) shell whenever such a plugin's screen is the + // active one. Empty until plugins resolve — the worst case is one extra + // syncActive() once the fetch lands, which re-applies the class. + const FULLSCREEN_PLUGIN_IDS = new Set(); + // ── Navigation registry ──────────────────────────────────────────────── // Each entry maps a stable hash key → a screen id (showScreen target) and a // label. Legacy screens are reused: "Songs" = #home (library), "Favorites" @@ -112,6 +119,15 @@ el.classList.toggle('text-fb-textDim', !on); }); setTopbarTitle(titleFor(screenId)); + // Immersive (full-screen) plugin screens: when the active screen belongs + // to a plugin that opted in via `"fullscreen": true`, collapse the host + // chrome (topbar hidden, sidebar → icon rail; see v3.css) and let the + // plugin own the content area. Toggled here so it tracks every + // navigation, including deep-link load and programmatic showScreen(). + const fsPluginId = (screenId && screenId.indexOf('plugin-') === 0) + ? screenId.slice('plugin-'.length) : null; + const immersive = !!(fsPluginId && FULLSCREEN_PLUGIN_IDS.has(fsPluginId)); + document.documentElement.classList.toggle('fb-immersive', immersive); // Show the song search only on the library screen. Everywhere else the // box is irrelevant (and would silently no-op against v3Songs.search). const searchWrap = document.getElementById('v3-search-wrap'); @@ -144,7 +160,7 @@ return '' + - iconSvg(entry.icon) + '' + esc(labelOverride != null ? labelOverride : entry.label) + ''; + iconSvg(entry.icon) + '' + esc(labelOverride != null ? labelOverride : entry.label) + ''; } // Empty slot for a promoted plugin, anchored after a nav item. Filled by // renderPromotedNav() only when the plugin is installed, so an absent @@ -161,7 +177,7 @@ const items = NAV.filter((n) => n.group === group); if (!items.length) continue; const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join(''); - html += '
' + + html += '
' + group + '
' + itemsHTML + '
'; } nav.innerHTML = html; @@ -270,6 +286,12 @@ if (res.ok) plugins = await res.json(); } catch (e) { return; } // degrade: no promoted slots const list = Array.isArray(plugins) ? plugins : []; + // Record which installed plugins requested immersive (full-screen) + // screens, then re-sync the active screen so the chrome collapses even + // if we navigated to a fullscreen plugin before /api/plugins resolved. + FULLSCREEN_PLUGIN_IDS.clear(); + for (const p of list) { if (p && p.fullscreen && p.id) FULLSCREEN_PLUGIN_IDS.add(p.id); } + try { syncActive(currentScreenId()); } catch (e) { /* non-fatal */ } for (const promo of PROMOTED_PLUGINS) { const host = document.getElementById(promo.slotId); const entry = byKey(promo.navKey); diff --git a/static/v3/v3.css b/static/v3/v3.css index 3867b3c..7337fcc 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -1074,3 +1074,39 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; } } .fb-settings-note { font-size: .75rem; color: #64748b; margin-top: 1rem; } .fb-tabpanel-empty { font-size: .8rem; color: #64748b; padding: .5rem 0; } + +/* ── Immersive (full-screen) plugin screens ────────────────────────────────── + A plugin that declares `"fullscreen": true` in its manifest gets the whole + content area when its screen is active. shell.js toggles `html.fb-immersive` + on navigation (see syncActive). DAW-style plugin UIs (e.g. a practice studio) + need the viewport, not a scrolling content page below the topbar — the cause + of the "cut off at the bottom / too much top padding" reports on the embedded + layout. Mirrors the proven chrome-hide pattern in static/v3/index.html + (`html.ss-follower-pre …`), but keeps the sidebar as a functional icon rail + so the user is never trapped (Escape is bound only on player/settings + scopes, not plugin screens). */ +html.fb-immersive #v3-topbar { display: none !important; } + +/* Sidebar → icon rail: narrow it, drop the wordmark + group headers + labels, + center the remaining icons. The .v3-nav-label / .v3-nav-group hooks are added + by shell.js so these rules don't depend on Tailwind utility class strings. */ +html.fb-immersive #v3-sidebar { width: 4.5rem; } +html.fb-immersive #v3-brand { display: none; } +html.fb-immersive #v3-nav { padding-left: .5rem; padding-right: .5rem; } +html.fb-immersive #v3-nav .v3-nav-group, +html.fb-immersive #v3-nav .v3-nav-label { display: none; } +html.fb-immersive #v3-nav a { + justify-content: center; + gap: 0; + padding-left: 0; + padding-right: 0; +} + +/* The active plugin screen fills the now-topbar-less content area. #v3-main is + position:relative, so pinning the screen avoids relying on the auto-height + block flow that made a 100vh plugin overflow #v3-main and force a scroll. */ +html.fb-immersive #v3-main > .screen.active { + position: absolute; + inset: 0; + overflow: hidden; +} diff --git a/tests/test_plugins.py b/tests/test_plugins.py index fc3e519..256d50c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -4035,3 +4035,35 @@ def test_settings_category_parsed_from_manifest(tmp_path, reset_plugin_state): assert rows["plainset"]["has_settings"] is True assert rows["noset"]["settings_category"] is None assert rows["noset"]["has_settings"] is False + + +def test_fullscreen_flag_parsed_from_manifest(tmp_path, reset_plugin_state): + """A plugin manifest's top-level `fullscreen: true` surfaces as the boolean + `fullscreen` on the loaded entry (drives the v3 shell's immersive mode). + Only a strict boolean `true` opts in — absent, false, or a truthy non-bool + (e.g. the string "true") all resolve to False so a plugin can't be opted in + by accident.""" + plugins = reset_plugin_state + + def _write(pid, manifest_extra): + d = tmp_path / pid + d.mkdir() + (d / "plugin.json").write_text(json.dumps({ + "id": pid, "name": pid, "routes": "routes.py", + "screen": "screen.html", **manifest_extra, + })) + (d / "routes.py").write_text("def setup(app, ctx):\n pass\n") + (d / "screen.html").write_text("
") + + _write("immersive", {"fullscreen": True}) + _write("strflag", {"fullscreen": "true"}) # truthy non-bool → not opted in + _write("falseflag", {"fullscreen": False}) + _write("noflag", {}) # field absent + + _run_load_plugins(plugins, type("FakeApp", (), {})(), tmp_path) + + rows = {p["id"]: p for p in plugins.LOADED_PLUGINS} + assert rows["immersive"]["fullscreen"] is True + assert rows["strflag"]["fullscreen"] is False + assert rows["falseflag"]["fullscreen"] is False + assert rows["noflag"]["fullscreen"] is False