Files
espresso_frame/.claude/skills/make-widget/SKILL.md
T
tfaour bcea090e73 Add LAYOUT_CONFIG_FIELDS step to the make-widget checklist
A new widget type shipping without an entry there fails silently --
no error, no test failure, it just saves/applies with an empty config
forever. Caught for real on the weather widget (37d57a1); adding the
step and a matching test-pattern bullet so the next widget type doesn't
repeat it.
2026-07-28 15:17:43 +00:00

11 KiB

name, description
name description
make-widget Scaffold a new widget type for the espresso_frame server (the ~14-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, saved-layout config allowlist, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").

Adding a widget type is a very consistent, repeated pattern in this codebase (photos/calendar/whiteboard/tasks/static) -- see docs/widgets.md for the system's actual data model/rendering/dialog architecture (read that first if you haven't). This skill is the checklist of every file that pattern touches, so nothing gets silently dropped (the static-image widget shipped without a docs/widgets.md update; this skill exists so that doesn't keep happening).

For a complete worked example touching every item below, git show 35e80c6 --stat (the static-image widget's commit) in this repo.

All paths below are relative to server/.

Before writing any code: shape decisions

Answer these first -- they determine which existing widget type is the closest template to copy from:

  • Live upstream to poll, or self-contained/user-authored? Calendar/ whiteboard/tasks fetch from somewhere external on a throttle (checked_at + get_or_refresh_* in routers/common.py). Photos' queue and the static image widget don't -- their content is set once via the dialog (an upload, a pick) and just sits there until changed. A text widget is almost certainly this second shape.
  • Single source, or multi-source merge? Calendar/tasks merge several people's data (FrameCalendar/FrameTaskList, owner-adds/ anyone-mutes). Only reach for that shape if the new type genuinely needs to combine several linked users' own data -- most new widget types are single-owner/single-config and don't need it.
  • Any button actions, or is ACTIONS = {} correct (nothing to advance/back/force)? Tasks and static image are both {}.
  • Minimum sane grid footprint -- how small can this widget be before its content is illegible/pointless?

Pick your template accordingly:

New widget shape Copy from
Self-contained, user-authored/uploaded, no fetch, no actions app/widgets/static_image.py
Single external source, throttled fetch, one "check_now" action app/widgets/whiteboard.py
Multi-source merge, owner-adds/anyone-mutes permissions app/widgets/tasks.py (simpler) or calendar.py (also has size-tier rendering)
Stateful queue/rotation with advance/back app/widgets/photos.py

The checklist

  1. app/models.py -- new <Type>WidgetConfig table, widget_id Mapped[int] primary key ForeignKey("widgets.id", ondelete="CASCADE"), plus whatever fields the type needs. Add it to the WIDGET_CONFIG_MODELS dict at the bottom of the file.
  2. app/grid.py -- add an entry to MIN_FOOTPRINT.
  3. app/widgets/<type>.py -- new module exposing:
    • render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image -- RGB, exactly target_w x target_h, never raises for a foreseeable failure (missing config, fetch error) -- fall back to ._shared.placeholder_image(target_w, target_h, [lines]) instead.
    • ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]
    • ACTION_LABELS: dict[str, str]
  4. app/widgets/__init__.py -- import the new module, add it to WIDGET_TYPES.
  5. app/migration.py -- new _migration_N. A brand-new table with no legacy data to carry forward is just Base.metadata.create_all(bind=conn) (see _migration_20) -- it only creates the one new table, existing ones are untouched. Register (N, _migration_N) as the new last entry in MIGRATIONS.
  6. app/routers/api_widgets.py:
    • Add any new Form(...) fields to api_widget_config_save's signature, and a new elif widget.widget_type == "<type>": branch inside its body. Reuse an existing field name (e.g. display_mode) where the semantics genuinely match -- fields are namespaced by which widget type actually reads them, not by name collision, so this is safe (see the comment above display_mode in that function).
    • Add type-specific endpoints as needed (upload/source-select/etc.). Use require_widget_control for widget-wide settings a dialog Save button changes; use require_widget_view (not control) for the owner-adds/anyone-mutes multi-source pattern, matching api_widget_calendar_select/api_widget_task_list_select.
    • Add a GET .../preview/<type> endpoint mirroring the others -- render_preview_png (the full palette/dither pipeline) for image-like content, or a dedicated render_<type>_preview_png in a rendering module for text/graphics content (see calendar_render.render_tasks_preview_png).
  7. app/routers/frame_pages.py -- import the new config model, add an if widget.widget_type == "<type>": branch in widget_dialog() returning templates.TemplateResponse("_widget_dialog_<type>.html", {...}).
  8. app/templates/_widget_dialog_<type>.html -- the dialog fragment: settings card(s) + <img class="preview-img" id="<type>-preview"> + a refresh button, using the existing .card/.card-title/.sub/.checkbox-row classes from theme.css rather than inventing new ones.
  9. app/static/widget_dialog_<type>.js -- an init<Type>Dialog()/ close<Type>Dialog() pair (not a page-load script -- see any existing widget_dialog_*.js's header comment for the contract). window.fetch already CSRF-injects (see common.js), so POSTs don't need a manual header. Never build user-supplied text into the DOM via innerHTML string interpolation -- use textContent/ createElement (a filename, a task summary, anything another linked user's account could have set is a stored-XSS vector otherwise).
  10. app/templates/frame_layout.html -- add <script src="/static/widget_dialog_<type>.js"></script> next to the other widget dialog scripts.
  11. app/static/frame_layout.js -- add the type to both DIALOG_INIT and DIALOG_CLOSE.
  12. app/static/common.js -- add a WIDGET_LABELS entry (the human label shown in the add-widget button, the widget box, and the button-assignment picker in frame_config.js).
  13. docs/widgets.md -- update every place that enumerates widget types: the intro sentence, the widget_type column-value list, the MIN_FOOTPRINT prose line, the app/widgets/ module list. This is the project's own "start here" doc per CLAUDE.md -- don't ship a widget without it staying accurate.
  14. app/routers/api_layouts.py -- add a "<type>": (...) entry to LAYOUT_CONFIG_FIELDS listing the config columns that are an authored setting (as opposed to runtime/cache state like a fetch cache or queue position, which a saved layout deliberately leaves out -- see the dict's own comment). Skipping this doesn't error or warn anywhere: the widget just silently saves/applies with an empty {} config forever, resetting to defaults on every layout apply or hold-to-cycle. This actually shipped missing for the weather widget -- caught only because a user noticed layout-cycling kept resetting its city/mode.

Tests (server/tests/)

  • test_widgets_<type>.py -- unit-level render() tests, no HTTP: correct size/mode with no config, with config, at grid.MIN_FOOTPRINT's smallest box, ACTIONS == {} if passive. Mirror test_widgets_static.py (self-contained) or test_widgets_tasks.py (fetch-backed, monkeypatches the fetch call).
  • test_widget_config_and_queue_endpoints.py -- add a _add_<type>_widget helper plus an HTTP-level test_config_save_updates_a_<type>_widget test, and tests for any new endpoints (upload/select/preview: 400 before configured, 200 after, 400 for the wrong widget type via _require_widget_type).
  • test_migrations.py -- add the new table to test_expected_columns_exist_on_current_schema's spot-check (inspector.get_table_names() or inspector.get_columns(...)).
  • Owner-adds/anyone-mutes multi-source table? Add cases to test_permission_boundaries.py following its existing calendar-select/task-list-select pattern (owner can add, non-owner can mute but not add, 404 for an unrelated widget id, 400 for the wrong widget type).
  • Any pure-logic helper module (decoding, parsing -- like app/image_upload.py) gets its own test_<module>.py: no HTTP, no DB, just the function.
  • test_saved_layouts.py -- a test_save_and_apply_round_trip_<type>_settings test: set every field the new LAYOUT_CONFIG_FIELDS entry lists, save a layout, assert the SavedLayoutWidget.config snapshot has them all, delete the frame's widgets, apply the layout back, assert the new widget's config matches -- and that any runtime/cache field (checked_at, a fetch cache, a queue) was not carried over. See test_save_and_apply_round_trip_weather_settings for the pattern.

Run the full suite before calling it done:

cd server && .venv/bin/pytest -q

Comfortably under 30s for the whole suite (~200+ tests) -- there's no reason to skip this or run a subset.

Browser verification (required, not optional)

Per CLAUDE.md, reading the JS is not enough -- this project has shipped UI bugs (mobile viewport CSS collapse, a dialog's status message landing behind its own backdrop, a JSON/form body mismatch) that only showed up live. Use the run-server skill:

  • Clear existing widgets and add one of the new type (POST /api/frames/1/widgets), resize it (PATCH), open its dialog (click .widget-box-settings), exercise its actual settings/upload flow through the real UI controls (not just a raw fetch in eval -- that only proves the endpoint works, not that the button is wired to it), and check console-errors for anything beyond the expected favicon 404.
  • Check the full composited panel preview (#frame-preview-thumb on /frames/{id}/config) actually shows the new widget's content -- not just its own dialog's preview/<type> image, which only proves the render function works in isolation.
  • Screenshot both desktop (viewport 1280 900) and mobile (the driver's default) widths -- the layout genuinely forks at the 860px breakpoint in theme.css.

Commit

One commit for the whole widget (models + migration + render + router + UI + tests + docs) -- this project's convention is one feature per commit, not split by layer. No Co-Authored-By: Claude trailer (see root CLAUDE.md).