First step of replacing Frame.mode (one renderer owns the whole panel)
with an Android-home-screen-style widget system -- a frame will hold N
independently placed/sized widgets (photos/calendar/whiteboard), each
with its own config/state, plus fully user-assignable NEXT/BACK button
actions. Full plan at .claude/plans/prancy-snacking-iverson.md.
This phase is additive only and changes no existing behavior -- nothing
reads these new tables yet:
- models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/
WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this
codebase's existing convention of dedicated tables for naturally-scoped
state rather than one wide table) + FrameButtonAction (ordered
(widget, action) bindings per physical button).
- grid.py: pure snap-to-grid placement math, defined relative to the
panel's long/short axis so it stays valid across
logical_render_size(orientation)'s genuine width/height swap for
portrait, not just a rotation applied at the end.
- db.py: widget_locked(), the widget-scoped equivalent of frame_locked()
-- deliberately still locks at frame granularity (not a new per-widget
lock) to avoid a new class of multi-lock deadlock bugs.
- migration.py: _migration_16 creates the new tables; a separate
_ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less
error-prone for this much per-mode branching) gives every existing
frame a widget reproducing its exact current mode/settings, so
upgrading changes nothing about what a frame displays or what its
buttons do. calendar_photo_inlay frames specifically get two widgets
(calendar + photo, split like the old inlay did) rather than silently
losing the photo half.
10 new tests covering fresh-install backfill, re-run idempotency, the
photo-inlay two-widget case, whiteboard's check_now button mapping, and
migration_16's actual CREATE TABLE path against a simulated pre-existing
database (not just the fresh-install create_all() shortcut). Full suite
(69 tests) passes.