--- name: make-widget description: 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 `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/.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 == "":` 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/` endpoint mirroring the others -- `render_preview_png` (the full palette/dither pipeline) for image-like content, or a dedicated `render__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 == "":` branch in `widget_dialog()` returning `templates.TemplateResponse("_widget_dialog_.html", {...})`. 8. **`app/templates/_widget_dialog_.html`** -- the dialog fragment: settings card(s) + `` + 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_.js`** -- an `initDialog()`/ `closeDialog()` 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 `` 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 `"": (...)` 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_.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__widget` helper plus an HTTP-level `test_config_save_updates_a__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_.py`: no HTTP, no DB, just the function. - `test_saved_layouts.py` -- a `test_save_and_apply_round_trip__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: ```bash 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/` 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`).