* chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0
Skins: no current scoreboard plugin builds on src.base_classes, so the only
skin hook (SportsCore._render_game) never runs. The plugin schema endpoint no
longer injects the Visual Skin dropdown, the store hides and refuses
"type": "skin" registry entries, and GET /api/v3/skins reports
supported: false with a message. Stored skin config still loads and saves.
src/skin_system/ and its tests are unchanged apart from the support flag.
Docs: check_plugin.py/render_plugin.py examples use --plugin; document
BasePlugin.get_update_interval() and its interaction with the manifest
update_interval; CLAUDE.md drops the stale template line number and
recommends display_manager.width/height.
Preview size: new src/display_geometry.py holds the size computation and
defaults DisplayManager uses (double-sided applied, chain_length default 2).
The web preview, /display/current, Starlark magnify default, sync handshake
and two dev scripts use it.
Release: __version__ 3.4.0, CHANGELOG 3.4.0 section plus a 3.3.0 tag note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: address CodeRabbit review on #580
- Preview fallbacks (SSE stream and /display/current) use logical_size({})
(128x32, the shared default) instead of a hard-coded 128x64.
- display_geometry treats a non-mapping display/hardware block as missing,
so a malformed config.json falls back to defaults instead of raising
AttributeError (which turned the Starlark render into an HTTP 500).
- Docs: the static update interval falls back manifest -> plugin config
-> 60s, in both the API reference and the architecture spec.
Not taken: validating double_sided copies against chain_length/parallel.
An orientation Rotate: or U-mapper pixel mapper decides which axis panels
lie on, so the counts would reject working setups (the existing
vertical-split test is one).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(display_geometry): a non-finite hardware size raises ValueError, not OverflowError
CodeRabbit flagged the Starlark magnify default in
_standalone_render_starlark_app for truthy non-mapping display values. That
case was already handled by a9e1bd0b (_display/_hardware treat a non-mapping
block as missing, covered by test_non_mapping_display_config_uses_the_defaults),
and the magnify it produces from the 128x32 defaults is the same as from 64x32.
Checking the same path found one input that still escaped: Python's JSON
parser accepts Infinity, and int(inf) raises OverflowError, which neither the
Starlark path (TypeError, ValueError) nor the preview stream in app.py caught,
so a hand-edited "rows": Infinity returned HTTP 500. physical_size now raises
ValueError for it, matching its documented contract, so every caller's
existing fallback applies. DisplayManager already caught Exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
10 KiB
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()insrc/base_classes/sports/core.py. - None of the current scoreboard plugins build on
src.base_classes. The official scoreboards in theledmatrix-pluginsmonorepo, and the third-party scoreboards in the plugin registry, carry their own sports and rendering code (with the sharedsrc/common/sports_*helpers) and never reachSportsCore._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/installanswers 400 with the reason). GET /api/v3/skinsstill lists what is inskins/, with"supported": falseand amessage.- 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; 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
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), so for them this seam is
never reached:
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.
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 <skin repo> skins/<skin-id>— that's the whole install. No manifest bumps, noupdate_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_infois kept: onceSKINS_RENDER_SUPPORTEDinsrc/skin_system/__init__.pyis true, such entries install through the sameplugins.jsonpipeline, land inskins/, are validated againstskin.json(including the API major version) instead ofmanifest.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
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).