# Skin System Architecture ## Status: not supported yet **Skins don't render with the current scoreboard plugins.** The skin system below works in isolation (it loads, validates and renders skins in `scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a running display calls it: - The only render hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`. - None of the current scoreboard plugins build on `src.base_classes`. The official scoreboards in the `ledmatrix-plugins` monorepo, and the third-party scoreboards in the plugin registry, carry their own sports and rendering code (with the shared `src/common/sports_*` helpers) and never reach `SportsCore._render_game()`. So a skin can be dropped into `skins/` and named in a plugin's config, but the scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the hook, core does not offer skins to users: - The plugin config page shows no **Visual Skin** dropdown. - The Plugin Store hides registry entries with `"type": "skin"` and refuses to install one (`POST /api/v3/plugins/install` answers 400 with the reason). - `GET /api/v3/skins` still lists what is in `skins/`, with `"supported": false` and a `message`. - A config that already contains `"skin"` / `"skin_options"` still loads, validates and saves unchanged; the value is simply unused. The rest of this document describes the design as built, for whoever wires a scoreboard to it. Skins are user-installable **visual overlays** for the sports scoreboards. A skin replaces only the *look* of a scoreboard — the host plugin keeps doing data fetching, scheduling, caching, dedup, live-priority takeover, and vegas mode. If you only want to **build** a skin, read [CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system works and why it is shaped this way. ## Why skins instead of forks Before skins, changing a scoreboard's layout meant forking the whole plugin (e.g. the community MLB scoreboard fork). The fork gets the new look but loses everything the maintained plugin keeps earning: duration/scheduling behavior, vegas mode support, caching and background-fetch improvements, bug fixes. It also silently drifts: every upstream improvement now has to be re-ported by hand. A skin inverts that trade. The plugin remains stock and keeps updating through the store; the skin is ~100 lines of pure rendering code that receives the plugin's already-fetched data each frame. Uninstalling the skin (or the skin crashing) simply restores the built-in look. ```text (unchanged) (the skin seam) ESPN API ──► update() ──► game view model ──► _render_game() ──► display fetching (a dict) │ │ caching │ └─ built-in scheduling └─ skin.render_(ctx, game) live priority draws onto ctx.canvas ``` ## The render funnel A sports scoreboard built on the `src/base_classes/sports/` package (`core.py`) renders through exactly one seam. No current scoreboard plugin is built on it (see [Status](#status-not-supported-yet)), so for them this seam is never reached: `SportsCore._render_game(game, force_clear)`. 1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`) picks `self.current_game` and calls `_render_game`. 2. `_render_game` lazily loads the configured skin (once, on first render — a broken skin can never block plugin startup). 3. If a skin is active, the host builds a `SkinContext` — a fresh black canvas at the current display size plus layout/font/logo helpers — and calls the skin's `render_live` / `render_recent` / `render_upcoming` with a **copy** of the game dict. 4. If the skin returns `True`, the canvas is composited onto the display. If it returns `False`, isn't implemented for that mode, or raises, the built-in `_draw_scorebug_layout` runs instead. Key properties that fall out of this design: - **Per-mode fallback.** A skin that only implements `render_live` gets the stock recent/upcoming screens for free. - **Three strikes.** A skin that raises 3 times in a row is disabled for the rest of the session (one loud error log per failure); the display never goes dark. Restarting the service re-arms it. - **Copies, not references.** Skins receive a shallow copy of the game dict, so a buggy skin cannot corrupt the plugin's scheduling state. - **Vegas mode works untouched.** Vegas capture falls back to grabbing the regular `display()` output, which is already skin-rendered. Skins can additionally implement `render_vegas_card` for purpose-built scroll cards, and hosts can call `SportsCore.render_skin_card(game, size)` to use it. - **Hot-loop caution.** `render_live` runs every display-loop pass during a live game. The host logs a warning when a skin render exceeds 150 ms, and `scripts/validate_skin.py` enforces a budget at development time — but Python cannot forcibly time-out a stuck render, so a skin that blocks (network I/O, giant image ops) stalls the display. This is why the rules in CREATING_SKINS.md ban I/O in render paths. ## The view model contract The `game` dict a skin receives is the plugin's already-extracted view model (`SportsCore._extract_game_details_common` plus per-sport extras from `src/base_classes/{baseball,basketball,football,hockey}.py`). - **Guaranteed keys (view model v1.0)** — always present for every sport: `id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`), `status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`, `home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score` (**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`. - **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`). - **Optional keys** (`odds`, rankings, `series_summary`, …) are present only when the feature is enabled — skins must always use `.get()`. Versioning policy: additive changes bump the minor version (`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as `ctx.view_model_version`); renaming or removing a guaranteed key requires a major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract` fails CI if a guaranteed key disappears from the extractor. Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`, `SkinContext`). The loader refuses a skin whose manifest declares a different major version and falls back to the built-in renderer with a clear "skin needs an update" log line. ## Package layout and lifecycle ```text skins// skin.json # manifest (required) skin.py # ScoreboardSkin subclass (required) preview.png # optional, shown by the web UI assets/ # optional skin-local images helpers.py ... # optional extra modules (namespaced per skin at import) ``` Skins live in the central `skins/` directory — deliberately **not** inside the plugin's directory, because plugin reinstall/update deletes the whole plugin directory and a skin must survive that. One skin can also target several plugins (mlb + milb). Lifecycle: discovered lazily on first render → manifest validated → API major version gated → module imported under a namespaced `sys.modules` key (two skins can both ship a `helpers.py`, same scheme plugins use) → instantiated with `(manifest, options)`. Every failure logs and falls back to built-in. Skins should be **stateless**: the live, recent, and upcoming mode classes each hold their own skin instance, so derive everything from `(ctx, game)`. ## Selection and configuration Inside the plugin's own config section in `config/config.json`: ```json "baseball-scoreboard": { "skin": "retro-baseball", "skin_options": { "accent_color": [255, 80, 0] } } ``` `"skin"` is either one id for all modes or a per-mode mapping (`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or `"built-in"` means the stock renderer. Because this rides the plugin's config section, it persists across plugin reinstalls like every other setting. `SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the *served* schema for plugins with matching skins installed. While skins are unsupported the plugin schema endpoint does not call it, so the dropdown is not shown. Validation never sees the enum either way: the base schema allows any `skin` value, so a config that references an uninstalled skin stays valid. `GET /api/v3/skins` lists installed skins (optionally filtered by `?plugin_id=`) and reports `"supported": false`. ## Distribution - **Manual:** `git clone skins/` — that's the whole install. No manifest bumps, no `update_registry.py`; skins are not monorepo plugins. - **Store (disabled while unsupported):** registry entries with `"type": "skin"` are hidden from the store list and refused on install. `PluginStoreManager._install_skin_from_info` is kept: once `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such entries install through the same `plugins.json` pipeline, land in `skins/`, are validated against `skin.json` (including the API major version) instead of `manifest.json`, and never install dependencies — skins are render-only (stdlib + PIL + the provided context, no third-party packages in v1). ## Trust model A skin is Python executing inside the display service — **exactly the same trust level as a plugin**, even though "skin" sounds cosmetic. Only install skins from sources you'd be willing to install a plugin from. ## v2 directions (not in v1) - A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins (weather, music) can offer skinnable layouts; `skin_runtime` is already sports-agnostic in anticipation. - Store UI: preview gallery, one-click install from the skin browser. - An update path for git-cloned skins (today: re-clone or store reinstall). - Animation support in skins (today the API is one frame per render call; stateful tricks work but are at-your-own-risk).