* Add skin system: user-installable visual overlays for sports scoreboards Skins restyle a scoreboard's live/recent/upcoming rendering while the host plugin keeps doing data fetching, scheduling, caching, live priority, and vegas mode — the anti-fork alternative for users who only want a different layout. - src/skin_system/: ScoreboardSkin API, SkinContext (canvas + adaptive layout + logo/font helpers), discovery/loading runtime with API major version gating and per-skin module namespacing - src/base_classes/sports.py: _render_game() seam at the three _draw_scorebug_layout call sites; skin-first with built-in fallback, 3-strikes session disable, slow-render warning; per-mode skin config - scripts/validate_skin.py: headless multi-mode/multi-size validator with bundled per-sport fixtures (no hardware or network needed) - skins/example-classic-baseball/: working reference skin - Web UI: served-schema Visual Skin dropdown (validation never enum-restricted, so uninstalled skins can't invalidate configs) and GET /api/v3/skins - Store: registry entries with type "skin" install to skins/ - docs/SKIN_SYSTEM.md (architecture), docs/CREATING_SKINS.md (author guide incl. Claude Code prompt), view-model contract locked by tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 * Address review feedback on skin system - skin_runtime: cache the entry module so the 2nd/3rd load of the same skin (live/recent/upcoming hosts) doesn't re-execute it with unbound sibling aliases; rebind cached sibling modules to their bare names around entry import and restore prior bindings after; include per- manifest mtimes in the discovery cache fingerprint so in-place skin updates are picked up - sports.py: count render_skin_card exceptions toward the 3-strike session disable - store_manager: validate skin ids (pattern + resolved-path containment in skins/), reject registry/manifest id mismatches, and stage+validate downloads in a temp sibling before replacing an existing skin - schema_manager: leave the schema untouched when the configured skin value is a per-mode mapping (a string dropdown could overwrite it) - validate_skin.py: reject non-positive sizes and non-object --options at parse time; support --output-dir outside the repo; type annotations - example skin: validate accent_color once at load with logged fallback - fixtures: pregame 0-0 scores in football/hockey upcoming fixtures - api /skins: rely on the self-invalidating discovery cache instead of force_refresh - docs: valid JSON manifest example, load_logo caching semantics spelled out, language ids on fenced blocks - tests: view-model contract test now exercises the real extractor; regression test for repeated same-skin loads with sibling modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 --------- Co-authored-by: Claude <noreply@anthropic.com>
8.3 KiB
Skin System Architecture
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; 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.
(unchanged) (the skin seam)
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
fetching (a dict) │ │
caching │ └─ built-in
scheduling └─ skin.render_<mode>(ctx, game)
live priority draws onto ctx.canvas
The render funnel
Every sports scoreboard (baseball, football, basketball, hockey — anything
built on src/base_classes/sports.py) renders through exactly one seam:
SportsCore._render_game(game, force_clear).
- The mode class's
display()(live,SportsUpcoming,SportsRecent) picksself.current_gameand calls_render_game. _render_gamelazily loads the configured skin (once, on first render — a broken skin can never block plugin startup).- 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'srender_live/render_recent/render_upcomingwith a copy of the game dict. - If the skin returns
True, the canvas is composited onto the display. If it returnsFalse, isn't implemented for that mode, or raises, the built-in_draw_scorebug_layoutruns instead.
Key properties that fall out of this design:
- Per-mode fallback. A skin that only implements
render_livegets 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 implementrender_vegas_cardfor purpose-built scroll cards, and hosts can callSportsCore.render_skin_card(game, size)to use it. - Hot-loop caution.
render_liveruns every display-loop pass during a live game. The host logs a warning when a skin render exceeds 150 ms, andscripts/validate_skin.pyenforces 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 UTCdatetime),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
skins/<skin-id>/
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:
"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.
The web UI shows a Visual Skin dropdown for plugins that have matching
skins installed: SchemaManager.inject_skin_selector adds an enum to the
served schema only. Validation never sees the enum — so a config that
references an uninstalled skin stays valid (rendering just falls back), and
the currently-configured value is always kept selectable. GET /api/v3/skins
lists installed skins (optionally filtered by ?plugin_id=).
Distribution
- Manual:
git clone <skin repo> skins/<skin-id>— that's the whole install. No manifest bumps, noupdate_registry.py; skins are not monorepo plugins. - Store: registry entries with
"type": "skin"install through the sameplugins.jsonpipeline;PluginStoreManagerroutes them toskins/, validatesskin.json(including the API major version) instead ofmanifest.json, and never installs 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
BasePluginopt-in (render_with_skin()) so non-sports plugins (weather, music) can offer skinnable layouts;skin_runtimeis 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).