---
name: make-widget
description: Scaffold a new widget type for the espresso_frame server (the ~13-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, 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.

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

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