Skip to content

Latest commit

 

History

History
283 lines (236 loc) · 15.5 KB

File metadata and controls

283 lines (236 loc) · 15.5 KB

CLAUDE.md

Guidance for Claude Code (claude.ai/code) when working in this repository.

What this is

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.

Repository layout

  • src/scrollkit/ — the library:
    • app/ScrollKitApp base class, the async run loop, memory helpers
    • display/UnifiedDisplay (auto-detects hardware vs simulator), the SimulatorDisplay, DisplayInterface, content classes
    • effects/ — the Transition content-swap system (transitions.py) with the OverlayMask/easing primitives, plus standalone splash/particle/text-render helpers (the old Effect/EffectsEngine systems were removed — see "Effects & transitions" below)
    • config/SettingsManager and transition_names.py (the single source of truth for transition names)
    • network/, ota/, utils/ — supporting subsystems
    • simulator/ — desktop pygame simulator (displayio emulation, fonts, and the core/ hardware-realism model)
    • dev/desktop-only developer/AI verification toolkit (raises ImportError on 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 tests
  • demos/ — runnable library demos (easy/, medium/, hard/)
  • docs/ + mkdocs.yml — documentation

Commands

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 suite
  • make 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-fix
  • make 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.

Releasing to PyPI (and deploying scrollkit.dev)

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:

  1. Bump the version in both pyproject.toml and src/scrollkit/__init__.py (__version__), and add a dated CHANGELOG.md section. PyPI versions are immutable — a published version number can never be replaced or reused, so never re-tag; bump again instead.
  2. Commit, then git tag v<version> && git push scrollkit master --tags (the remote is named scrollkit, not origin).
  3. The tag push triggers publish.yml: run the whole CI suite against the tagged commit (ci.yml is 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 match pyproject.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 with gh run watch; confirm with curl -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).

The dev / verification toolkit (scrollkit.dev — desktop only)

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 report
  • capabilities() — JSON-able catalog (content types, priorities, effects, transitions + their feasibility budgets, colors, display API), introspected from live code so it can't drift
  • validate(app) — structured pre-flight issues with concrete fixes
  • performance_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.

Recording video & GIFs — the recorder already exists; do NOT rebuild it

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 frame

Reuse 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.

Hardware feasibility + calibration

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).

CircuitPython compatibility (CRITICAL)

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__.py does no eager submodule imports (every import costs RAM) — keep it that way.

One display, dev == hardware (CRITICAL)

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).

Effects & transitions: one contract (post-consolidation)

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 preallocated OverlayMask (C bitmaptools ops — never a per-frame allocation or a per-pixel Python loop). Each carries a FEASIBILITY dict on the class (CircuitPython can't attach attributes to functions). DropFromSky is a duck-typed sibling, not a Transition subclass — enumerate via _TRANSITION_MAP, never Transition.__subclasses__().
  • Single source of truth for transition names: the literal-only config/transition_names.TRANSITION_NAMES feeds the settings UI, and effects/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_names imports nothing, so the device boot path never pays for effects/ (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 Transition contract): the splash animations (reveal_splash / drip_splash / swarm_reveal), particles, and text_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, and SimpleContext exists only for callers that own no tiles. effects/mark.PixelMark is the minimal mark for anything with no app to own one. Dispatch mirrors transitions exactly: a literal BUILDS/DWELLS/EXITS dict plus act_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 through ActScheduler. Two things are refused rather than offered broken, and that judgement is the point: Drop from Sky (its pre_render_hook means start() never calls the swap callback, so a mark handed to it stays hidden) and the two map_route treatments (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) raises FeasibilityError if an effect allocates per frame or busts the ~50 ms (20 fps) budget. The annotated reference is demos/medium/golden_transition.py; the contributor guide is the "Adding your own transition" section of docs/guide/transitions.md.

Thread safety: the web server must never modify the message queue

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.

Code style

  • 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.
  • PascalCase classes, snake_case functions/vars, UPPERCASE constants.
  • Specific try/except with 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).