### Description
Spec-first PR that now **also implements** the "dashboard widge…ts declared by integrations" capability. The spec is `docs/specs/external-integrations/capabilities/dashboard-widgets.md` (self-contained capability file, per the layout rules of the external-integrations spec), and the new technical type has its own file, `capabilities/provider-type.md`; the code follows them section by section.
#### Spec
- Design principles: the integration describes *what*, Gladys decides *how*; widget declared in the manifest, content produced at runtime in a core vocabulary; a widget cannot be ugly by construction (style and composition constrained by a content budget and a canonical slot order); payload never trusted; "trigger, not data"; `widgets` is a capability any type may declare.
- `capabilities/provider-type.md`: the technical type `provider` for capability-only integrations (no device surface, configuration-only page, at least one capability field required, hidden from non-admins), the "a new capability is a manifest field, never a type" rule, the bar for a future type, the naming decision recorded; `communication` and `weather` keep their meaning as primary contracts. C.1 and the catalog spec point at it.
- Manifest `widgets` field, the `external-widget` box, the data path (settings validation, cache keyed by settings / language / units, coalescing, bounds, nudge with a generation counter that lifecycle changes move too), the content vocabulary (with the chart interval enum, **chart `annotations` and `now_marker`** from the forum test round, the `version` lower bound and normalized credential-free `https` links) and its budget, images served through the core with byte and pixel bounds (**each refusal named with its measured detail; the integration resizes its images, the core never recompresses**), actions allowlisted from the requesting user's content variant, live device bindings, frontend states, security, tests, two verification journeys (provider / device).
#### Implementation — server
- `validateManifest` + vendored `manifest.schema.json`: `widgets` (≤ 5, key regex, label/description bounds, icon shape, settings restricted to `string|number|boolean|select|multi_select|section` with `{{port:}}` refused, `action_timeout_seconds`) and the `provider` type (must declare a capability field).
- `normalizeWidgetContent`: per-component whitelist and bounds (texts with ellipsis, finite numbers, ISO dates, `https` links without credentials served as their normalized `href`, semantic enums incl. the chart `interval`), chart `annotations` (≤ 8 of `{ t, value?, label ≤ 16, color }`, on both chart forms) and `now_marker` (kept only when strictly `true`), unknown types/fields dropped, envelope `version` (≥ 1, higher than supported → dedicated code) / `ttl_seconds` / 256 KB, the content budget (8 components, one focal, 6 tiles, 2 texts, 1 status, 4 buttons, unique action keys), content order preserved.
- `resolveWidgetDeviceReferences`: tenant-scoped resolution of `device_feature` / `device_features` → `device_feature_selector(s)`, read-only and range checks on buttons, gauge range inherited from the feature.
- Widget data path on the manager: `getWidgets` (picker list, non-admin friendly, `integration_status` only), `getWidgetContent` (settings validation with the shared engine and dynamic devices, canonical cache key, LRU 50, coalescing, generation counter, 2 in flight / 30 misses per minute → 429, `expires_at` response), `handleWidgetRefresh` (1 per 10 s per widget, drop + `widget-updated` broadcast), `getWidgetImage` (regex check, allowlist from cached contents, PNG/JPEG/WebP ≤ 300 KB **and ≤ 4096 px per side read from the header, fail closed**, 1 h cache, LRU 100, 4 in flight; **every refusal has its own code — too large, unsupported format, dimensions exceeded, invalid payload — with the measured value against the bound, logged with the integration selector and image key and echoed as the `error` of the route's 400**, so a poster served at 378 KB by a CDN is diagnosed in one look instead of an afternoon), `runWidgetAction` (30 per minute, content resolved through the content path with the session `(language, units)`, params from the content, declared timeout, invalidation + broadcast, bounded `{ message }`), error translation to `REQUEST_TO_THIRD_PARTY_FAILED` (+ bounded `error`) and `WIDGET_CONTENT_VERSION_UNSUPPORTED`; on stop / update / uninstall the generations move (never reset) and the caches, in-flight commands and slots are dropped, so a pre-stop pull never refills the cache.
- Routes: `GET /api/v1/external_integration/widget`, `GET .../:selector/widget/:key/content`, `GET .../:selector/image/:image_key`, `POST .../:selector/widget/:key/action/:action_key`; WS dispatch of `external-integration.widget.refresh`; new WS types and `DASHBOARD_BOX_TYPE.EXTERNAL_WIDGET`; Joi box schema (`integration` / `widget` required, bounded `settings`).
#### Implementation — frontend
- `ExternalWidgetBox`: the five canonical slots (header, tiles incl. an SVG gauge, chart / card list with detail panel / lazy image, body + status list, button pills) and every state of the spec (not installed, stopped with admin link, skeleton, unavailable with retry and bounded error, check settings, needs a newer Gladys, empty, 429 keeps content); refresh on `expires_at`, `widget-updated`, `STATUS_CHANGED`, `websocket.connected`; live `DEVICE.NEW_STATE` on bound tiles and buttons; widget actions with confirm and message, device-feature buttons through the standard value route. Charts draw the content's annotations (a dot on the curve when a value is given, a vertical line otherwise, short labels in the semantic colors, edge labels anchored inward) and the dashed "now" marker when the series spans the current time, at a fixed height; the shared `ApexChartComponent` only gains an opaque `annotations` option.
- `EditExternalWidgetBox` on the shared `ConfigSchemaForm` engine (moved to `components/integration/`), `source: "devices"` from `GET /api/v1/service/:selector/device`; the form shows the declared default of absent discrete settings (toggle, select), so it never disagrees with the live preview.
- Picker: the core widgets and the integration widgets under their own heading ("Gladys widgets" / "Integration widgets", shown as soon as an installed integration declares widgets), one tile per widget of every installed integration (`data-cy="box-type-external-widget-<selector>-<key>"`) with the integration name as caption; the existing search matches the widget label, key, description and integration name.
- `provider` type: no device screens, hidden from non-admins, config landing, install-screen disclosure of the declared widgets.
- Translations en / fr / de; Cypress journey `DashboardExternalWidgetBox.cy.js` (stubbed widget list and content), passing locally.
#### Tests
Server: manifest rules, normalizer (one block per component + budget, chart annotations and now marker), cache / coalescing / nudge / bounds / LRU / language variants / lifecycle, images (formats, size, header-read dimensions, fail-closed headers, one code and detail per refusal, log + echoed `error`, a command timeout neither logged as a refusal nor echoed), actions, device bindings, controllers (supertest, non-admin list, session-derived language/units, route collision), dashboard model, WS dispatch — 100% lines on the new modules; the full server suite passes.
Manual end-to-end run (no Docker: three seeded external services and a small Node WebSocket client playing the integrations, answering `widget.get` / `widget.get-image` / `widget.action`): two `provider` integrations (a movie card grid with posters and a detail panel; fuel prices with tiles, an inline area chart, a status list and a link button) and one `device` integration (a vacuum widget bound to live device features, a cleaning-map image, widget actions with an integration message and a device-feature button). Checked the picker and the editor form, the light / dark / mobile renderings, the action round trip with content invalidation, and the six frontend states (stopped, not installed, invalid settings, third-party error with retry, unsupported content version, degraded badge). That run surfaced the editor default-values fix above. A second run after the forum test round checked the annotated fuel chart (two point annotations, one vertical marker, the "now" line) in light and dark mode.
### Related request
Forum: https://community.gladysassistant.com/t/10641
### Checklist
- [x] If a forum topic or GitHub issue exists, the description links it (`Forum: https://community.gladysassistant.com/t/...` or `Closes #...`)
- [x] Tests pass: `cd server && npm run coverage` (Codecov requires 100% coverage on changed lines) and Cypress (`npm run cypress:run`) if the UI changed — server suite green locally with 100% lines on the new modules; the widget Cypress spec passes locally against the built frontend
- [x] Linter and prettier pass on both front and server (`npm run eslint`, `npm run prettier`) — plus `compare-translations` and a Vite build
- [x] No undocumented breaking change
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Mym9VS4QcYR7ZxvJVS2oGh