Guidance for Claude Code (claude.ai/code) when working in this repository.
ScrollKit is a library for building scrolling LED-matrix displays that run
unchanged on the Adafruit MatrixPortal S3 (CircuitPython 8.x/9.x) and on a
desktop pygame simulator. The library lives entirely in src/scrollkit/.
This repo is the library only. The ThemeParkWaits application that uses it
lives in its own repository (czei/themeparkwaits, checked out at
../themeparkwaits) — do not add application code here.
For building apps with the library (the imperative API, content types, the
verification loop, and the device-measured performance cheat-sheet), see
AGENTS.md at the repo root.
src/scrollkit/— the library:app/—ScrollKitAppbase class, the async run loop, memory helpersdisplay/—UnifiedDisplay(auto-detects hardware vs simulator), theSimulatorDisplay,DisplayInterface, content classeseffects/— theTransitioncontent-swap system (transitions.py) with theOverlayMask/easing primitives, plus standalone splash/particle/text-render helpers (the oldEffect/EffectsEnginesystems were removed — see "Effects & transitions" below)config/—SettingsManagerandtransition_names.py(the single source of truth for transition names)network/,ota/,utils/— supporting subsystemssimulator/— desktop pygame simulator (displayio emulation, fonts, and thecore/hardware-realism model)dev/— desktop-only developer/AI verification toolkit (raisesImportErroron CircuitPython by design)
test/unit/— the test suite (headless, simulator-based)test/claude/— host-side device tooling (raw-REPL driver, calibration, microbenchmarks) — not collected as testsdemos/— runnable library demos (easy/,medium/,hard/)docs/+mkdocs.yml— documentation
The package lives under src/, so tests/scripts run with an env prefix.
PYTHONSAFEPATH=1 keeps the CWD off sys.path.
make test-unit— run the unit suitemake test-all— run all tests- Single test:
PYTHONSAFEPATH=1 PYTHONPATH=src python -m pytest test/unit/path/test_file.py::TestClass::test_method -v make lint— ruff with auto-fixmake lint-errors— critical-error check (undefined names, syntax errors)make test-coverage— coverage report
ALWAYS run make test-unit and make lint-errors after any change; both must
be green before considering work complete. CI (.github/workflows/ci.yml) runs
the same two on push/PR (Python 3.11 & 3.13) via pip install -e ".[dev]", which
pulls the [simulator] extra (pygame + numpy + Pillow) — so packaging regressions
fail CI.
The package is published on PyPI as scrollkit (first release: 0.8.3,
2026-07-02). Uploads happen only via GitHub Actions Trusted Publishing
(OIDC): PyPI is configured to trust .github/workflows/publish.yml running in
czei/scrollkit with the pypi environment. There is no API token anywhere —
don't add one, and don't try to twine upload from a local machine.
To cut a release:
- Bump the version in both
pyproject.tomlandsrc/scrollkit/__init__.py(__version__), and add a datedCHANGELOG.mdsection. PyPI versions are immutable — a published version number can never be replaced or reused, so never re-tag; bump again instead. - Commit, then
git tag v<version> && git push scrollkit master --tags(the remote is namedscrollkit, notorigin). - The tag push triggers
publish.yml: run the whole CI suite against the tagged commit (ci.ymlis called as a reusable workflow), build sdist+wheel,twine check --strict, verify the wheel carries the simulator package data, then upload. It fails fast if the tag doesn't matchpyproject.toml's version, and a red CI blocks the upload — the two workflows used to be independent, which is how 0.9.2 and 0.9.3 both shipped on a failing CI run. Watch withgh run watch; confirm withcurl -s https://pypi.org/pypi/scrollkit/json.
Packaging gotcha: non-.py files inside the package (the simulator's BDF
fonts, the calibration JSONs) ship only because of
[tool.setuptools.package-data] in pyproject.toml. CI installs editable and
therefore can not catch missing package data; the publish workflow greps
the built wheel as a guard. When adding new in-package data files, extend both
the declaration and the guard.
The docs site is separate from PyPI releases: make deploy-docs builds MkDocs
and rsyncs it to scrollkit.dev (details in the deployment skill).
This is how an app is built and checked against the simulator before flashing:
run_headless(app, frames=N, screenshot=path) -> RunResult— deterministic headless render with pixel metrics + a hardware feasibility reportcapabilities()— JSON-able catalog (content types, priorities, effects, transitions + their feasibility budgets, colors, display API), introspected from live code so it can't driftvalidate(app)— structured pre-flight issues with concrete fixesperformance_guide()— per-operation costs measured on a real device
scrollkit.dev pulls in numpy/pygame and must never be imported from device
code or from the core library (app/, display/, the top-level __init__).
It raises ImportError immediately on CircuitPython.
STOP before writing any screen-capture, ffmpeg-wrapping, frame-grabbing, or
image-stitching code. ScrollKit already records the simulator to PNG, animated
GIF, and MP4 (H.264). This is the built-in, calibrated path that produced the
scrollkit.dev landing-page hero video and every Demo Gallery GIF. If you are
asked to "make a video / GIF / preview / screenshot" of an app or effect, use the
existing API below — do not add a new dependency, a new recorder module, a new
pygame frame loop, or a parallel ffmpeg pipeline.
The two entry points (full how-to + tuning in AGENTS.md → "Recording video &
animated GIFs", and docs/guide/simulator.md):
# 1) High-level: record a whole ScrollKitApp headlessly (the usual choice).
from scrollkit.dev import record_gif, record_video
record_gif(MyApp(), "preview.gif", seconds=4) # animated GIF (needs Pillow)
record_video(MyApp(), "hero.mp4", seconds=6) # MP4/H.264 (needs ffmpeg on PATH)
# 2) Low-level: capture frames you render yourself off a SimulatorDisplay.
display.start_recording()
# ... await display.show() in a loop (each shown frame is captured) ...
display.save_gif("out.gif") # or display.save_video("out.mp4")
display.screenshot("frame.png") # single PNG of the current frameReuse the generator scripts too — demos/render_gifs.py (Demo Gallery GIFs, run
make docs-gifs) and demos/render_hero.py (landing-page hero MP4/GIF/poster, run
make hero). Dependencies ship in the [simulator] extra (pygame + Pillow +
numpy); MP4 additionally needs a system ffmpeg (brew install ffmpeg). All of it
is desktop-only and a no-op (returns None) on hardware. If something is
missing or wrong in the recorder, fix the recorder, don't route around it.
The simulator can model the real device's speed and RAM so problems surface
before flashing (the classic trap: it looks great at desktop speed but crawls on
the ~100×-slower device). Opt in with SimulatorDisplay(hardware_timing=True) or
SCROLLKIT_HW_SIM=1; the visceral real-time crawl is throttle=True /
SCROLLKIT_HW_THROTTLE=1.
The model is calibrated from a real MatrixPortal S3. The baseline ships at
src/scrollkit/simulator/core/matrixportal_s3_baseline.json and the
per-operation microbenchmark table at device_benchmarks.json. Recapture both
with test/claude/calibrate_device.py and test/claude/device_benchmarks.py
(needs a board on USB serial; uses the raw-REPL driver in test/claude/cpy_repl.py,
which writes nothing to the device).
The library must run on CircuitPython 8.x/9.x (a subset of MicroPython), not just desktop Python. Before using any standard-library feature, exception, or module, verify it exists in CircuitPython.
| Standard Python | CircuitPython alternative | Notes |
|---|---|---|
json.JSONDecodeError |
ValueError |
json.loads() raises ValueError on bad JSON |
FileNotFoundError |
OSError |
only OSError exists, not the subclasses |
pathlib.Path |
os operations |
no pathlib |
urllib.parse |
manual string parsing | no urllib |
threading |
asyncio |
cooperative multitasking only |
subprocess |
not available | cannot spawn processes |
typing (at runtime) |
remove / comment hints | no typing module on device |
enum.auto() |
explicit values | auto() not available |
time.time() |
time.monotonic() |
wall clock unreliable |
random.choices() |
random.choice() in a loop |
choices() not available |
random.shuffle() |
hand-rolled Fisher-Yates | also sample/gauss/*variate absent — only random/uniform/randint/randrange/getrandbits/choice/seed exist (static guard: test_circuitpython_compat.py) |
f-string f"{x=}" |
regular f-strings | = debug syntax unsupported |
match/case |
if/elif |
no pattern matching |
Required pattern:
# WRONG (desktop only) # CORRECT (CircuitPython compatible)
except json.JSONDecodeError: except ValueError:
except FileNotFoundError: except OSError:Other device realities to design around:
- HTTP is synchronous (
adafruit_requests), so a fetch blocks the display loop. Break long work into chunks and render a "loading" frame before blocking. - The device is RAM-constrained and ~100× slower than desktop; the top-level
scrollkit/__init__.pydoes no eager submodule imports (every import costs RAM) — keep it that way.
There is a single display implementation used in both the dev simulator and on CircuitPython. If the simulator's output doesn't match reported hardware behavior, fix the simulator code, not the shared display logic. No exceptions.
Performance follows the device measurements (see AGENTS.md for the full
cheat-sheet): reuse Labels instead of allocating/rebuilding one per frame, use
C bulk calls (bitmap.fill, bitmaptools.blit) rather than per-pixel Python
loops, and keep bit_depth=4 (≈3× faster refresh than 6).
The effects subsystem was consolidated to a single content-swap contract plus
standalone helpers. Do not reintroduce the removed systems: the Effect ABC
/ EffectRegistry / CompositeEffect, the SimpleEffect / EffectsEngine system,
the EnhancedDisplayContent family, and the with_effect / add_effect
attachment API on DisplayItem / BaseContent (and DisplayQueue._apply_effects)
are all gone.
- The one contract is
Transition(effects/transitions.py): cover → swap-while-hidden → reveal. Subclasses implement_paint_cover(progress)/_paint_reveal(progress)with bounded, bulk writes into the preallocatedOverlayMask(Cbitmaptoolsops — never a per-frame allocation or a per-pixel Python loop). Each carries aFEASIBILITYdict on the class (CircuitPython can't attach attributes to functions).DropFromSkyis a duck-typed sibling, not aTransitionsubclass — enumerate via_TRANSITION_MAP, neverTransition.__subclasses__(). - Single source of truth for transition names: the literal-only
config/transition_names.TRANSITION_NAMESfeeds the settings UI, andeffects/transitions._TRANSITION_MAP/transition_factory()own the name→class dispatch. A unit test keeps the two in lockstep (ordered) and asserts that importing settings does not load the effects package —transition_namesimports nothing, so the device boot path never pays foreffects/(RAM). To add a selectable transition, edit those two places (same order); a custom one-off can override_get_transition()instead. - Standalone, orthogonal (NOT the
Transitioncontract): the splash animations (reveal_splash/drip_splash/swarm_reveal),particles, andtext_render. Leave them as-is. - Acts are a SECOND contract, deliberately distinct from
Transition, and not a merger of it (effects/acts.py). A transition swaps one screen's content for another's; an act is a beat in a sign's show over a mark: build → dwell → exit. Keep the two categories separate: an act wraps a transition (reveal_via/hide_via), it does not replace or subsume one, and the splashes and treatments stay their own categories too. The act contract is a duck-typed context, not a base class:ctx.slots/.colors/.display/.running/await .frame()/.show()/.hide(). An app passes itself; there is nothing to inherit, andSimpleContextexists only for callers that own no tiles.effects/mark.PixelMarkis the minimal mark for anything with no app to own one. Dispatch mirrors transitions exactly: a literalBUILDS/DWELLS/EXITSdict plusact_factory()/supported_acts(), lazily imported, not a registry or plugin loader.selectable()expands those seven functions into the 39 name-level choices a menu shows;play_sign()drives them throughActScheduler. Two things are refused rather than offered broken, and that judgement is the point:Drop from Sky(itspre_render_hookmeansstart()never calls the swap callback, so a mark handed to it stays hidden) and the twomap_routetreatments (they need a mark's own stroke paths). - The safety mechanism for any new effect is the strict gate, not a plugin
loader:
run_headless(app, strict=True)raisesFeasibilityErrorif an effect allocates per frame or busts the ~50 ms (20 fps) budget. The annotated reference isdemos/medium/golden_transition.py; the contributor guide is the "Adding your own transition" section ofdocs/guide/transitions.md.
The web server runs in a separate context and must never mutate display/queue state. It may only update settings the main loop reads and set flags the main loop checks. The message queue is owned solely by the main display-loop thread.
- Find the root cause of problems; do not paper over issues (e.g. missing data). If intent is ambiguous, ask before acting.
- Imports grouped: stdlib, third-party (Adafruit), then project modules.
PascalCaseclasses,snake_casefunctions/vars,UPPERCASEconstants.- Specific
try/exceptwith the CircuitPython-correct exception types. - Docstrings on classes and methods.
- Documentation, plans, and design docs go in
plans/. - Temporary/scratch programs go in
test/claude/. - Include hardware-abstraction fallbacks so code degrades gracefully off-device.
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan:
specs/002-build-scrollkit-showcase/plan.md (ScrollKit Showcase Effects —
zero-allocation micro-show engine: removal of broken effects, a strict
hardware-feasibility gate, shared primitives, and three signature effect classes).