mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +00:00
feat(plugins): full-screen (immersive) plugin screens via manifest opt-in (#590)
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 <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
8b4c9b0050
commit
4c3ec2ff66
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 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 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 `<config_dir>/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).
|
- **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 `<config_dir>/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).
|
||||||
|
|||||||
@@ -1350,6 +1350,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
_icon = "assets/thumb.png"
|
_icon = "assets/thumb.png"
|
||||||
except OSError:
|
except OSError:
|
||||||
_icon = None
|
_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 {
|
return {
|
||||||
"id": plugin_id,
|
"id": plugin_id,
|
||||||
"name": manifest.get("name", 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_script": bool(manifest.get("script")),
|
||||||
"has_settings": bool(manifest.get("settings")),
|
"has_settings": bool(manifest.get("settings")),
|
||||||
"settings_category": _settings_category,
|
"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")),
|
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
|
||||||
# `styles` is an optional relpath (under the plugin's assets/) to a
|
# `styles` is an optional relpath (under the plugin's assets/) to a
|
||||||
# compiled, preflight-off stylesheet the frontend injects as 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_screen": p["has_screen"],
|
||||||
"has_script": p["has_script"],
|
"has_script": p["has_script"],
|
||||||
"has_settings": p["has_settings"],
|
"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`
|
# Settings-tab placement; None when the manifest's `settings`
|
||||||
# is absent, a bare string, or omits `category`.
|
# is absent, a bare string, or omits `category`.
|
||||||
"settings_category": p.get("settings_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_script": e.get("has_script", False),
|
||||||
"has_settings": e.get("has_settings", False),
|
"has_settings": e.get("has_settings", False),
|
||||||
"settings_category": e.get("settings_category"),
|
"settings_category": e.get("settings_category"),
|
||||||
|
"fullscreen": e.get("fullscreen", False),
|
||||||
"has_tour": e.get("has_tour", False),
|
"has_tour": e.get("has_tour", False),
|
||||||
"has_styles": e.get("has_styles", False),
|
"has_styles": e.get("has_styles", False),
|
||||||
"styles": e.get("styles"),
|
"styles": e.get("styles"),
|
||||||
|
|||||||
+24
-2
@@ -17,6 +17,13 @@
|
|||||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ────────────────────────────────────────────────
|
// ── Navigation registry ────────────────────────────────────────────────
|
||||||
// Each entry maps a stable hash key → a screen id (showScreen target) and a
|
// Each entry maps a stable hash key → a screen id (showScreen target) and a
|
||||||
// label. Legacy screens are reused: "Songs" = #home (library), "Favorites"
|
// label. Legacy screens are reused: "Songs" = #home (library), "Favorites"
|
||||||
@@ -112,6 +119,15 @@
|
|||||||
el.classList.toggle('text-fb-textDim', !on);
|
el.classList.toggle('text-fb-textDim', !on);
|
||||||
});
|
});
|
||||||
setTopbarTitle(titleFor(screenId));
|
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
|
// Show the song search only on the library screen. Everywhere else the
|
||||||
// box is irrelevant (and would silently no-op against v3Songs.search).
|
// box is irrelevant (and would silently no-op against v3Songs.search).
|
||||||
const searchWrap = document.getElementById('v3-search-wrap');
|
const searchWrap = document.getElementById('v3-search-wrap');
|
||||||
@@ -144,7 +160,7 @@
|
|||||||
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
|
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
|
||||||
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
|
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
|
||||||
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
|
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
|
||||||
iconSvg(entry.icon) + '<span class="truncate">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
|
iconSvg(entry.icon) + '<span class="truncate v3-nav-label">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
|
||||||
}
|
}
|
||||||
// Empty slot for a promoted plugin, anchored after a nav item. Filled by
|
// Empty slot for a promoted plugin, anchored after a nav item. Filled by
|
||||||
// renderPromotedNav() only when the plugin is installed, so an absent
|
// renderPromotedNav() only when the plugin is installed, so an absent
|
||||||
@@ -161,7 +177,7 @@
|
|||||||
const items = NAV.filter((n) => n.group === group);
|
const items = NAV.filter((n) => n.group === group);
|
||||||
if (!items.length) continue;
|
if (!items.length) continue;
|
||||||
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
|
||||||
html += '<div><div class="px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
html += '<div><div class="v3-nav-group px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
|
||||||
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
|
||||||
}
|
}
|
||||||
nav.innerHTML = html;
|
nav.innerHTML = html;
|
||||||
@@ -270,6 +286,12 @@
|
|||||||
if (res.ok) plugins = await res.json();
|
if (res.ok) plugins = await res.json();
|
||||||
} catch (e) { return; } // degrade: no promoted slots
|
} catch (e) { return; } // degrade: no promoted slots
|
||||||
const list = Array.isArray(plugins) ? plugins : [];
|
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) {
|
for (const promo of PROMOTED_PLUGINS) {
|
||||||
const host = document.getElementById(promo.slotId);
|
const host = document.getElementById(promo.slotId);
|
||||||
const entry = byKey(promo.navKey);
|
const entry = byKey(promo.navKey);
|
||||||
|
|||||||
@@ -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-settings-note { font-size: .75rem; color: #64748b; margin-top: 1rem; }
|
||||||
.fb-tabpanel-empty { font-size: .8rem; color: #64748b; padding: .5rem 0; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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["plainset"]["has_settings"] is True
|
||||||
assert rows["noset"]["settings_category"] is None
|
assert rows["noset"]["settings_category"] is None
|
||||||
assert rows["noset"]["has_settings"] is False
|
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("<div></div>")
|
||||||
|
|
||||||
|
_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
|
||||||
|
|||||||
Reference in New Issue
Block a user