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.
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_*inrouters/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
app/models.py-- new<Type>WidgetConfigtable,widget_idMapped[int]primary keyForeignKey("widgets.id", ondelete="CASCADE"), plus whatever fields the type needs. Add it to theWIDGET_CONFIG_MODELSdict at the bottom of the file.app/grid.py-- add an entry toMIN_FOOTPRINT.app/widgets/<type>.py-- new module exposing:render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image-- RGB, exactlytarget_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]
app/widgets/__init__.py-- import the new module, add it toWIDGET_TYPES.app/migration.py-- new_migration_N. A brand-new table with no legacy data to carry forward is justBase.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 inMIGRATIONS.app/routers/api_widgets.py:- Add any new
Form(...)fields toapi_widget_config_save's signature, and a newelif 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 abovedisplay_modein that function). - Add type-specific endpoints as needed (upload/source-select/etc.).
Use
require_widget_controlfor widget-wide settings a dialog Save button changes; userequire_widget_view(not control) for the owner-adds/anyone-mutes multi-source pattern, matchingapi_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 dedicatedrender_<type>_preview_pngin a rendering module for text/graphics content (seecalendar_render.render_tasks_preview_png).
- Add any new
app/routers/frame_pages.py-- import the new config model, add anif widget.widget_type == "<type>":branch inwidget_dialog()returningtemplates.TemplateResponse("_widget_dialog_<type>.html", {...}).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-rowclasses fromtheme.cssrather than inventing new ones.app/static/widget_dialog_<type>.js-- aninit<Type>Dialog()/close<Type>Dialog()pair (not a page-load script -- see any existingwidget_dialog_*.js's header comment for the contract).window.fetchalready CSRF-injects (seecommon.js), so POSTs don't need a manual header. Never build user-supplied text into the DOM viainnerHTMLstring interpolation -- usetextContent/createElement(a filename, a task summary, anything another linked user's account could have set is a stored-XSS vector otherwise).app/templates/frame_layout.html-- add<script src="/static/widget_dialog_<type>.js"></script>next to the other widget dialog scripts.app/static/frame_layout.js-- add the type to bothDIALOG_INITandDIALOG_CLOSE.app/static/common.js-- add aWIDGET_LABELSentry (the human label shown in the add-widget button, the widget box, and the button-assignment picker inframe_config.js).docs/widgets.md-- update every place that enumerates widget types: the intro sentence, thewidget_typecolumn-value list, theMIN_FOOTPRINTprose line, theapp/widgets/module list. This is the project's own "start here" doc perCLAUDE.md-- don't ship a widget without it staying accurate.app/routers/api_layouts.py-- add a"<type>": (...)entry toLAYOUT_CONFIG_FIELDSlisting 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-levelrender()tests, no HTTP: correct size/mode with no config, with config, atgrid.MIN_FOOTPRINT's smallest box,ACTIONS == {}if passive. Mirrortest_widgets_static.py(self-contained) ortest_widgets_tasks.py(fetch-backed, monkeypatches the fetch call).test_widget_config_and_queue_endpoints.py-- add a_add_<type>_widgethelper plus an HTTP-leveltest_config_save_updates_a_<type>_widgettest, 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 totest_expected_columns_exist_on_current_schema's spot-check (inspector.get_table_names()orinspector.get_columns(...)).- Owner-adds/anyone-mutes multi-source table? Add cases to
test_permission_boundaries.pyfollowing 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 owntest_<module>.py: no HTTP, no DB, just the function. test_saved_layouts.py-- atest_save_and_apply_round_trip_<type>_settingstest: set every field the newLAYOUT_CONFIG_FIELDSentry lists, save a layout, assert theSavedLayoutWidget.configsnapshot 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. Seetest_save_and_apply_round_trip_weather_settingsfor 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 rawfetchineval-- that only proves the endpoint works, not that the button is wired to it), and checkconsole-errorsfor anything beyond the expected favicon 404. - Check the full composited panel preview
(
#frame-preview-thumbon/frames/{id}/config) actually shows the new widget's content -- not just its own dialog'spreview/<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 intheme.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).