CodeQL (py/path-injection): plugin_id arrives in request input and flows
into filesystem paths via find_plugin_dir. Gate it with the same
^[a-zA-Z0-9_-]{1,64}$ allowlist the web UI's pages_v3 uses, at the
single choke point every route resolves through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
- docs: scope the self.layout note to BasePlugin subclasses (others build
a LayoutContext directly) and make explicit that adaptive layout is
opt-in — classic rendering stays unless a plugin adopts the APIs.
- dev_server: broaden the render-request catch (a bad manifest.json now
returns a clean 400 instead of an unhandled 500) and stop echoing raw
exception text in the loader-failure responses — full tracebacks go to
the dev server's console log instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
logo_slot = min(height, width // 2) has a blind spot: at exactly 2:1
aspect ratio (width == 2 * height -- a very common shape: two, four, or
more square modules stacked into a taller panel) width // 2 and height
are equal, so the two logo slots claim the ENTIRE width and leave zero
pixels for a center column, no matter how large the panel gets. Not a
'small panel' problem -- 96x48, 128x64, and 256x128 (all exactly 2:1) hit
it identically, while the 128x32 design baseline and panels like 192x48
or 256x32 never do, because height is already the tighter constraint
there.
Two new parameters fix it in the one shared helper every scoreboard-style
plugin composes through:
- min_center_fraction / min_center_design_px reserve at least
max(width * fraction, design_px * ctx.scale) for the center column,
capping logo_slot further when needed. The scaled design-px term
matters on small panels where a flat fraction alone reserves too little
absolute space.
- score_bleed_fraction extends the score's own fit box (not the logo
slots themselves) a controlled amount into each side -- the same way
real broadcast scoreboards let a big score number's edges cross into
the team marks flanking it. Without this the reserve alone can still be
too narrow for a short score to render without truncating.
score_area is now genuinely narrower than the full card width (previously
identical to status_band/detail_band, which still span the full width and
overlay the logos -- short text there was never the problem).
Verified against the full harness size spread: a real game score like
'17-21' never needs ellipsis at any tested 2:1-or-tighter aspect ratio
(test_score_never_needs_ellipsis_for_a_short_score), and wide panels
(128x32/192x48/256x32-style) are provably unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
self.scale (min(width_ratio, height_ratio)) is the right conservative
default for anything whose aspect ratio matters, but a caller whose
surrounding composition already scales along a single axis — e.g.
football-scoreboard's logo_slot = min(height, width // 2), which tracks
height alone — needs text sized the same way, or it reads as
under-scaled next to logos that grew on a panel that only got taller
(128x32 -> 128x64: self.scale stays 1.0 since width didn't grow, but
logos still double).
fit_text_proportional(..., scale=None) now accepts an explicit override;
None keeps the existing self.scale default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fit_text always picks the largest ladder rung that fits its box. That's
right when an element owns dedicated space, but wrong when several
independently-fitted elements need to stay visually harmonious as the
panel grows: a score's box might have generous room while a neighboring
logo scales by a fixed geometry factor via px() — fit_text lets the score
balloon out of proportion (even overlapping the logo) even though its
individual pick is technically correct.
fit_text_proportional(text, box, base_size_px, ladder) instead targets
base_size_px * self.scale (the same scale factor px() already uses),
picking the nearest ladder rung at or below that target, still capped to
what fits the box, floored at the smallest rung when the target is below
every rung. Refactored the shared largest-that-fits/ellipsize walk into
_walk_ladder() so fit_text and fit_text_proportional don't duplicate it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PIL antialiases TTF outlines by default; a 'pixel-style' font only
rasterizes without antialiasing at specific sizes (for PressStart2P:
exact multiples of its 8px design grid). A ladder rung at an unverified
size silently renders blurry on an LED panel — this exact bug shipped in
both text-display's and football-scoreboard's custom TTF ladders
(non-8-multiple PressStart2P sizes, and '5by7.regular'/'4x6-font' at
sizes that were never actually crisp).
measure_font_crispness(font, sample_text) renders the sample and reports
the fraction of ink-bbox pixels that are neither pure black nor pure
white. BDF fonts (real bitmaps) always score 0.0; TTF ladders should be
verified against this before shipping — see the new
TestFontFitting::test_ladder_arcade_is_crisp pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Discoverability: re-export the adaptive layout/image API from src.common
(the blessed-helpers package plugin authors already know) — canonical
paths stay src.adaptive_layout / src.adaptive_images so nothing breaks.
Document it in src/common/README.md and cross-link ADAPTIVE_LAYOUT.md
from the developer docs authors actually read (quick reference, API
reference, advanced dev, font manager, dev preview, plugin dev guide);
ADAPTIVE_LAYOUT.md gains adaptive-images, composite-layouts and
preserving-user-customization sections.
Compat: PluginLoader now logs one advisory warning (never raises) when a
plugin's manifest declares a min LEDMatrix version newer than the running
core, checking the min_ledmatrix_version / requires.* / versions[]
spellings found in the wild. Guarded against stale core version numbers.
src/__init__.py __version__ bumped 1.0.0 -> 3.1.0 to match the latest
release tag (v3.1.0) — it had never been updated and the compat check
needs a truthful number. NOTE: verify this matches the intended release
numbering before the next tag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quality gates for adaptive layout:
- fill_metrics()/check_scale_up() in the safety harness: overflow catches
content too big for a panel, but nothing caught content that stays tiny
on panels >= 2x the plugin's declared design size. The check measures
lit-content extents and warns (or fails, when a plugin opts into
"fill_check": "strict" in test/harness.json) below 50% coverage on the
doubled axis. Warn-only by default so no existing plugin breaks.
- harness.json "variants": extra runs with config overlays and their own
golden dirs, so an opt-in mode (e.g. layout_mode: adaptive) is golden-
tested beside the classic default. check_plugin.py loops base + variants
and labels variant results mode@name.
- Dev preview server: GET /api/sizes (harness size sample), POST
/api/render-matrix (render at up to 12 sizes in one call), size-preset
dropdown, and an "All Sizes" side-by-side gallery in the preview UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add src/adaptive_images.py — the image counterpart to fit_text:
- fit_image(img, box, mode=contain|cover|fill_height|stretch,
crop_to_ink, anchor, resample, upscale) promoting the proven plugin
patterns (football's crop-to-ink fill-height logos, masters' cover
crop + NEAREST flags, static-image's letterbox). Upscales by default —
thumbnail()'s downscale-only behavior is why imagery stays tiny on
big panels.
- draw_fitted_image() pastes aligned within a Region with alpha mask.
- One central Pillow>=9.1 RESAMPLE shim replacing ~15 plugin copies.
LayoutContext.fit_image() caches results per (identity, box size,
options) with a 64-entry LRU; id()-keyed entries pin the source image.
BasePlugin.draw_image() is the one-liner adoption path beside draw_fit.
Composites in adaptive_layout.py: Region.offset() (user x/y-offset
passthrough), scoreboard_regions() (the two-logos-plus-score card math
duplicated across six sports plugins, logo_slot = min(H, W//2)), and
media_row() (art-left/text-right).
Fix LogoHelper's size-blind cache key (stale sizes on panel change);
deprecation note on dead image_utils.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add src/adaptive_layout.py — opt-in core helpers so plugins render
legibly on any panel size without hand-tuned per-display layouts:
- Region: integer rect algebra (bands/columns/weighted splits/centering)
that partitions space so text bands can't overlap by construction
- Font ladders: ordered (family, size) steps known to render crisply
(LADDER_GRID: X11 BDFs at native sizes; LADDER_ARCADE: PressStart2P at
8px multiples) — fitting walks the ladder instead of scaling pixel
fonts fractionally
- LayoutContext: breakpoint tiers, geometry scale vs. a declared design
size, and cached fit_text/fit_lines/font_for_rows queries
Generalizes the three patterns proven in the field: f1-scoreboard's
scale factor, masters-tournament's tiers, baseball-scoreboard's font
fallback ladder.
Wiring: BasePlugin gains a lazy .layout property and draw_fit();
FontManager gains get_native_bdf_size() and a cache_generation counter;
manifest schema gains display.design_size and requires.display_size
max_width/max_height; 96x48 joins DEFAULT_TEST_SIZES; the bounds-check
harness records negative-coordinate draws; TextHelper's broken
measurement helpers are fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>