@@ -5,12 +5,51 @@ All notable changes to ScrollKit are recorded here. This project loosely follows
55
66## [ Unreleased]
77
8- Two changes for a caller nobody had before: a ** live preview ** that wants to run at the
9- device's speed rather than as fast as it can. Both are opt-in, and neither moves a pixel —
10- modeled cost is computed from operation counts, never from wall time, so feasibility
11- reports and per-frame hashes are identical with them on or off .
8+ ## [ 0.11.0 ] - 2026-08-21
9+
10+ Brightness that actually dims, a panel renderer that no longer needs pygame, and the
11+ pixel-art chapter the docs never had .
1212
1313### Added
14+ - ** A pygame-free rendering backend, and a 3.7x faster panel composite.** pygame is a C
15+ extension over SDL with no wasm build — not in Pyodide's package set, no emscripten
16+ wheels for pygame-ce on PyPI — so six calls (` Surface ` , ` SRCALPHA ` , ` draw.circle ` ,
17+ ` transform.smoothscale ` , ` BLEND_RGB_ADD ` , ` image.save ` ) stranded the entire LED cosmetic
18+ layer on the desktop, and a browser preview showed flat squares instead of a panel.
19+ ` simulator/core/_surface.py ` picks a backend: pygame where importable, numpy where not.
20+ pygame stays primary wherever it exists, so desktop output is untouched — verified by
21+ frame hash, unchanged before and after. The composite also replaces up to 4,096 small
22+ per-LED array ops with a handful of whole-array writes: dots tile exactly on the pitch,
23+ and 20 px glow sprites overlap on an 11 px pitch but not on a two-cell sub-lattice, where
24+ the pitch is 22 ≥ 20 — so four passes cover the panel with no overlap inside any pass.
25+ Additive saturation is order-independent, so the result is bit-identical rather than
26+ merely close, and the test asserts that. 13.6 → 3.67 ms/frame, and measured end to end in
27+ Pyodide, 190.7 → 64.8 ms/frame (5.2 → 15.4 fps), with the paint handoff going 127 → ~ 1 ms
28+ once the panel composes into a persistent RGBA buffer the host wraps zero-copy.
29+ - ** ` scrollkit.utils.pixel_art ` — ` normalize_art() ` , ` normalize_all() ` , ` art_problems() ` .**
30+ Hand-authored ASCII art has exactly two typos, and both crash from deep inside the
31+ conversion loop with nothing on the panel: a ragged row throws ` IndexError ` , an unmapped
32+ character throws ` KeyError ` . Across six documented model-written signs, every
33+ first-attempt failure was one of those two and nothing else — one of them a single row of
34+ 25 characters where its two neighbours were 26, in a 598-line program with 34 sprites,
35+ which cost two full regeneration rounds. Short rows now pad with transparent, unmapped
36+ whitespace becomes transparent, and any other unmapped character becomes the first lit
37+ slot, because the author drew something there and substituting transparent would silently
38+ delete the sprite — a worse outcome than the crash. Every repair reports one line naming
39+ the sprite, and clean art passes through untouched. ` art_problems() ` returns the same
40+ findings as a list for tests that would rather assert than repair.
41+ - ** A pixel-art chapter.** ` docs/guide/pixel-art.md ` covers art as ASCII rows over palette
42+ slots, authoring small and doubling, the width arithmetic for 64x32, converting to a
43+ ` Bitmap ` once, and then animating with palette writes, tile moves and hidden flags rather
44+ than redrawing. ` demos/medium/pixel_wordmark.py ` is the worked example: hand-authored
45+ letterforms, a sprite on its own material slots, and four acts through an ` ActScheduler ` ,
46+ at ~ 9 palette writes a frame. Given the library's own docs, three models each built a
47+ competent multi-scene sign — correct areas, real tool lists, inside the frame budget —
48+ and not one drew a picture. ` AGENTS.md ` had no pixel-art chapter and led its content
49+ section with ` ScrollingText ` /` StaticText ` , so a text-oriented prompt produced text signs.
50+ - ** ` UnifiedDisplay.set_color_scale(brightness) ` and ` .color_scale ` ** — the software dimmer
51+ behind the brightness change below. Synchronous; ` set_brightness() ` remains as the async
52+ wrapper for existing callers.
1453- ** ` run_headless ` / ` run_headless_async ` take ` throttle=None|True|False ` .** The harness
1554 forced the performance manager's throttle off on every headless run, which is right for a
1655 test suite and left a preview with no supported path to hardware-speed playback at all.
@@ -31,7 +70,54 @@ reports and per-frame hashes are identical with them on or off.
3170 monotonic run total, kept separate from the bounded ` frames ` history a clock built on it
3271 would run backwards on.
3372
73+ ### Changed
74+ - ** Brightness is a software colour scale now, not a hardware property.** ` display.brightness `
75+ on the MatrixPortal S3 is not a dimmer — it is effectively on/off: 0.0 blanks the panel and
76+ 0.15 looks identical to 1.0 (confirmed on hardware, six live changes, no visible
77+ difference). The re-platform had replaced a working software dimmer with it, so the
78+ brightness setting did nothing across its whole range for the entire 3.x line, and a
79+ stored "0" left a customer's sign dark for months with a perfectly healthy app behind it.
80+ The panel is now pinned to FULL and colours are scaled on their way out, from a RAW
81+ cached base — pre-dimming the base looks cheaper but double-dims ` CoverAnimator ` , which
82+ builds its overlay colours from ` base_colors ` and passes them through the same
83+ ` _make_overlay ` that dims. Covered: ` draw_text ` /` draw_text_scaled ` , the ` set_pixel ` /` fill `
84+ paint path, the gradient ramp (with ` color_scale ` in the layer cache key, or a scrolling
85+ name would keep its old brightness until the content cycled), all five ` BitmapText `
86+ palette effects, the write-once effect palettes, every icon overlay animator, the pulse,
87+ and the three cloned palettes ` SpriteLift ` /` FrameCycle ` /` GravityDrip ` build. ** Note for
88+ upgraders:** a stored brightness that has been silently ignored will now take effect, so
89+ a sign configured low will visibly dim on this release.
90+ - ** ` pixel_write_us ` is measured now, not guessed.** The feasibility gate false-rejected a
91+ field-proven act at 17.7 fps against a 20 fps target, and the whole error was one
92+ constant: ` pixel_writes ` decided the verdict (92% of that act's frame) and the term is
93+ ` set_pixel_calls * 3 * profile.pixel_write_us ` , where ` pixel_write_us ` was a hardcoded
94+ 5.0 that never came from a device — inside a profile reporting
95+ ` confidence: CALIBRATED_FROM_DEVICE ` . ` calibrate_device.py ` now measures it (and takes
96+ ` --out ` , so a run can be inspected without overwriting the shipped baseline). On a
97+ MatrixPortal S3 it is 4.333 µs, of which the write is 3.36 µs and 74% of per-pixel cost
98+ is Python loop overhead — which is why a per-pixel effect is expensive on this board
99+ regardless of what it writes. The board measured runs CircuitPython 10.2.1 while the rest
100+ of the baseline is 9.1.0; frame-time terms are stable across the two (bitmap_rebuild
101+ +0.4%, full_refresh +0.7%), which is why this one is mixed in, while memory is * not*
102+ (usable_ram −24.6% on 10.2.1) and is deliberately left at 9.1.0. The baseline records
103+ that in ` _pixel_write_us_source ` .
104+
34105### Fixed
106+ - ** A browser preview locked the page and painted nothing.** ` unified.py ` 's no-pygame
107+ branch skipped the await, so the whole run blocked until it finished; it now refreshes,
108+ records and yields like the pygame path. ` save_surface_png ` opened with ` import pygame `
109+ and returned ` None ` on ` ImportError ` , which left the public ` display.screenshot() ` broken
110+ in exactly the environment the numpy backend exists for, while
111+ ` matrix.save_screenshot() ` worked. ` capture_frame ` and ` save_surface_png ` handle both
112+ backends now.
113+ - ** ` swarm_reveal ` was not reproducible across Python versions.** Running 35 acts in
114+ CPython 3.12 and again in Pyodide's 3.13, 34 matched frame for frame and ` swarm ` diverged
115+ from frame 0 — not flakiness, since two runs with the same seed are identical. The queue
116+ came from ` list(self._remaining) ` over a set, and ` _shuffle() ` permutes whatever order it
117+ is handed; set iteration order is stable within one Python build but is not guaranteed
118+ across versions, so seeding the RNG was not sufficient. ` sorted() ` makes the seeded
119+ shuffle the only source of order. Worth stating as a general rule for anything compared
120+ across runtimes: never derive an ORDER from iterating a set or dict.
35121- ** Throttled pacing overshot every frame.** It slept the whole modeled frame cost * after*
36122 the frame had already rendered, so a frame took ` real_work + modeled ` rather than
37123 ` max(real_work, modeled) ` and a host faster than the device still ran slower than it, by
@@ -68,6 +154,24 @@ reports and per-frame hashes are identical with them on or off.
68154 and every health signal read green. It is still caught, but recorded to ` error_log ` and
69155 exposed as ` display_init_error ` .
70156
157+ ### Docs
158+ - ** The reveals table had no signatures.** It showed ` DripReveal ` 's call with ` color=BRAND `
159+ and then listed ` SwarmReveal ` , ` show_reveal_splash ` and the transitions with none at all.
160+ A model reading that generalised the keyword it had seen and wrote
161+ ` SwarmReveal(pixels, color=...) ` , which died at frame 1 with
162+ ` TypeError: unexpected keyword argument 'color' ` — ` SwarmReveal ` takes ` text_color= ` and
163+ ` bird_color= ` and has no ` color= ` . Reasonable inference from what the page showed; the
164+ page was the problem. The table now carries each constructor's actual colour arguments
165+ and says plainly that they are not shared.
166+ - ** The ` random() ` availability list was written from recollection.** The warning box
167+ asserted seven functions including ` getrandbits ` and ` seed ` , neither of which is called
168+ anywhere in this library or in the reference sign — the same failure mode as a profile
169+ reporting ` CALIBRATED_FROM_DEVICE ` for a term nobody measured. It now states the five the
170+ shipped library and the field-proven sign actually call (` random ` , ` uniform ` , ` randint ` ,
171+ ` randrange ` , ` choice ` ) as the working set, with the provenance attached, and says what
172+ ` ActScheduler ` does instead of shuffling, since "use the scheduler" without the reason
173+ invites a hand-rolled deck anyway.
174+
71175## [ 0.10.0] - 2026-08-02
72176
73177A sensor layer, and the network work from three field failures on a fielded
0 commit comments