Files
LEDMatrix/docs/CREATING_SKINS.md
cdf03fb107 Add skin system: user-installable visual overlays for sports scoreboards (#419)
* 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>
2026-07-18 11:07:08 -04:00

11 KiB
Raw Permalink Blame History

Creating Skins

A skin restyles a sports scoreboard (live / recent / upcoming) without forking the plugin: the plugin keeps fetching data, scheduling, caching, and doing vegas mode; your skin only draws. Architecture background: SKIN_SYSTEM.md.

Quick start

cp -r skins/example-classic-baseball skins/my-skin
# edit skins/my-skin/skin.json  -> set id ("my-skin"), name, author, class_name
# edit skins/my-skin/skin.py    -> rename the class, start restyling
python scripts/validate_skin.py --skin my-skin

The validator renders your skin against bundled fixture games at several panel sizes with no hardware, no network, no running service, saves PNGs (plus 4x previews) to skin_renders/, and fails loudly on errors. Iterate: edit → validate → look at the PNGs.

To see it on your matrix, add to your plugin's section in config/config.json:

"baseball-scoreboard": {
  "skin": "my-skin",
  "skin_options": { }
}

or pick it from the Visual Skin dropdown in the web UI (it appears once a matching skin is installed). "skin" also accepts a per-mode mapping: {"live": "my-skin", "recent": "built-in"}.

The manifest (skin.json)

{
  "id": "my-skin",
  "name": "My Skin",
  "version": "1.0.0",
  "author": "you",
  "description": "What it looks like",
  "skin_api_version": "1.0.0",
  "targets": {
    "sports": ["baseball"],
    "sport_keys": ["mlb", "milb"],
    "plugins": []
  },
  "entry_point": "skin.py",
  "class_name": "MySkin",
  "modes": ["live", "recent", "upcoming"],
  "preview": "preview.png"
}

Field notes: id must equal the directory name; skin_api_version's major version must match the host's SKIN_API_VERSION or the skin is refused at load; targets takes sport families (sports), exact sport keys (sport_keys), and/or exact plugin ids (plugins) — any match applies.

The renderer (skin.py)

from src.skin_system.skin_base import ScoreboardSkin, SkinContext

class MySkin(ScoreboardSkin):
    def render_live(self, ctx: SkinContext, game: dict) -> bool:
        score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
        fit = ctx.layout.fit_text(score, ctx.layout.bounds)
        ctx.draw_fit(fit, ctx.layout.bounds)
        return True   # True = "I drew it"; False = use the built-in layout

Implement only the modes you care about — anything else falls back to the plugin's built-in rendering. Return False to decline a specific game (e.g. a layout that only makes sense while a game is live).

The rules (they keep your skin from breaking the display)

  1. Draw only onto ctx.canvas (via the helpers or ctx.draw). Never reassign ctx.canvas, never touch the display or call any update method.
  2. No I/O in render paths. No network, no file loads per frame — render_live runs every display pass, and a slow render stalls the whole matrix (the host warns at >150 ms). Use ctx.load_logo (cached) and cache_key= for images.
  3. Derive everything from (ctx, game). Skins must be stateless: the live/recent/upcoming modes each get their own instance.
  4. Always .get() optional keys. Only the guaranteed keys below are promised to exist.
  5. Never hardcode pixel positions for the panel. Use ctx.width/ ctx.height, ctx.layout regions and fit_text — your skin will be run at sizes you didn't test (64x32, 128x64, vegas cards).
  6. No third-party dependencies. Stdlib + PIL + what ctx provides.

A skin that raises 3 renders in a row is disabled until the service restarts (the built-in layout takes over), so a bug is cosmetic — but check your logs.

SkinContext reference

Member What it is
ctx.canvas / ctx.draw Fresh RGB PIL.Image at display size + its ImageDraw (raw-PIL escape hatch)
ctx.width, ctx.height Canvas size — the only size truth
ctx.layout LayoutContext (see ADAPTIVE_LAYOUT.md): bounds, fit_text, fit_text_proportional, fit_image, px, by_tier
ctx.draw_fit(fit, box, color, align, valign) Draw a fit_text result aligned in a Region (handles BDF fonts)
ctx.draw_text(text, x, y, color, font) Positioned text (handles BDF fonts)
ctx.draw_image(img, box, mode, align, valign, cache_key) Fit + paste an image with alpha; no-ops on None
ctx.load_logo("home" | "away") Team logo as RGBA, or None (always handle None). Cached after first use; see note below
ctx.draw_text_outlined(text, (x, y), font, fill, outline_color) The classic scorebug outlined text (TTF fonts only)
ctx.fonts The host's font dict — keys score, time, team, status, detail, rank
ctx.options Your user's skin_options from config
ctx.sport, ctx.view_model_version, ctx.logger Context metadata + logger

A note on ctx.load_logo vs the no-I/O rule: load_logo is the one sanctioned exception. It goes through the host's logo cache — after the first call per team it's a pure in-memory lookup. If a logo file is missing on disk, the first call may download it, exactly like the built-in renderer does for the same game (a skin is never worse than built-in here). Always pass a stable cache_key when drawing it, never load image files yourself in a render path, and always handle None.

The default layout idiom — carve regions, then fit text into them:

from src.adaptive_layout import scoreboard_regions

regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
fit = ctx.layout.fit_text("3-5", regions.score_area)
ctx.draw_fit(fit, regions.score_area)

Region supports split_h/split_v/inset/top_band/bottom_band/ left_col/right_col for custom carves. Raw ctx.draw.rectangle/polygon/ ellipse/... is always available for custom marks (see the bases diamond in the example skin).

The game view model

Guaranteed for every sport (view model v1.0 — renaming these breaks skins and is treated as a breaking change upstream):

Key Notes
id Event id (string)
status_text Display-ready status, e.g. "Final", "7:30 PM", "Bot 7th"
is_live, is_final, is_upcoming, is_halftime Booleans
game_date, game_time Pre-formatted local date/time strings
start_time_utc UTC datetime
home_abbr, away_abbr Team abbreviations (can be 25 chars — fit, don't assume)
home_id, away_id Team ids
home_score, away_score Strings, not ints
home_record, away_record "58-33" or "" (0-0 records are blanked)
home_logo_path, away_logo_path Prefer ctx.load_logo over touching these

Sport extras (present for that sport, still .get() defensively):

  • baseball: inning (int), inning_half ("top"/"bottom"), balls, strikes, outs (ints), bases_occupied ([first, second, third] booleans), series_summary (str)
  • football: period, period_text, clock, home_timeouts, away_timeouts, down_distance_text, down_distance_text_long, is_redzone, possession, possession_indicator ("home"/"away"), scoring_event
  • basketball: period, period_text, clock
  • hockey: period, period_text, clock, power_play, penalties, home_shots, away_shots

Optional everywhere (only when the user enabled the feature): odds (dict), series_summary, rankings-related fields.

Fixture copies of these dicts live in src/skin_system/fixtures/ — that's exactly what the validator feeds your skin.

Vegas mode

You get vegas support for free: vegas captures the normal display output, which is already your skin's rendering. Optionally implement render_vegas_card(ctx, game) to return a purpose-built card at ctx.width x ctx.height (sizes vary — never assume 128x32).

Building a skin with Claude Code

Skins are ideal Claude Code projects: small, isolated, and verifiable with one command. Paste this to start:

You are building a display skin for LEDMatrix — a visual overlay for a sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels). First read docs/CREATING_SKINS.md and the reference skin in skins/example-classic-baseball/.

Rules:

  • Create/modify files ONLY under skins/<my-skin-id>/. Do NOT modify anything in src/, scripts/, the plugins, or any other skin.
  • Render only from the game dict and ctx helpers. No network calls, no per-frame file I/O, no new pip dependencies, no touching the display — draw onto ctx.canvas and return True.
  • Use ctx.layout regions and fit_text for positioning so the skin works at any panel size; use .get() for every optional game key.
  • After every change run python scripts/validate_skin.py --skin <my-skin-id> and LOOK at the PNGs it writes to skin_renders/ (the _x4.png files are easiest to read). Iterate until it passes and looks right at both 128x32 and 64x32.

What I want it to look like: <describe your layout — where logos, score, status go; colors; what shows during live vs upcoming vs final>

Tips that keep Claude (and you) out of trouble:

  • One mode at a time: get render_live right before touching the others — unimplemented modes automatically use the built-in look.
  • Ask for edge-case renders: long team abbreviations, missing logos (ctx.load_logo returning None), 0-0 records, extra innings/OT.
  • If the render looks cramped at 64x32, ask Claude to use ctx.layout.by_tier(...) to drop elements on small panels rather than shrinking everything.
  • Never let it "fix" a problem by editing src/ — if the skin can't do something within its directory, that's a feature request, not a workaround.

Pre-publish checklist

  • python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64 passes
  • Looked at every PNG in skin_renders/ — nothing clipped or overlapping
  • Handles a missing logo (None) without crashing — temporarily point a fixture's logo path at a nonexistent file to test
  • Long abbreviations ("TA&M", 45 chars) don't overflow
  • No render warning above the time budget
  • skin.json: id matches the directory, version set, skin_api_version matches the host, targets correct
  • preview.png added (grab your favorite _x4 render)
  • Tested on real hardware if you have it — a Pi is much slower than your dev machine

Distribute by publishing the directory as a git repo (users git clone <repo> skins/<id>), or submit it to the plugin registry as an entry with "type": "skin" (see SKIN_SYSTEM.md §Distribution).

Trust note: a skin is Python running inside the display service — the same trust level as a plugin. Review code before installing skins from others.