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:
ChrisBeWithYou
2026-06-25 00:02:16 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 8b4c9b0050
commit 4c3ec2ff66
5 changed files with 106 additions and 2 deletions
+1
View File
@@ -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).
+13
View File
@@ -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
View File
@@ -17,6 +17,13 @@
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
+36
View File
@@ -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;
}
+32
View File
@@ -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