diff --git a/.agents/rules/widgets.md b/.agents/rules/widgets.md index 7719b28070f5..5c4139c1ca78 100644 --- a/.agents/rules/widgets.md +++ b/.agents/rules/widgets.md @@ -1,12 +1,17 @@ # Widgets -A widget is a folder under `widgets/`, auto-discovered by convention (no registration): +A registered dashboard widget is a folder under `widgets/`, auto-discovered by convention +(no registration): +- `package.json` — workspace package for the lazy-loaded render bundle. - `widget.json` — static metadata (name, title, description, category, presentation). - `widget.ts` — live metadata (default export: title, icon, attributes, example). - `render.tsx` — default-export React component. - `style.module.css` — optional; CSS Modules, tokens from `@wordpress/theme` (`--wpds-*`). +These rules apply to registered dashboard widgets. Presentational-only folders under +`widgets/` are out of scope unless they are being converted into registered widgets. + The render component is bound by `WidgetRenderProps` from `@wordpress/widget-primitives`: it receives only `{ attributes, setAttributes }`. `attributes` may arrive empty — default it (`= {}`). @@ -14,9 +19,52 @@ The render component is bound by `WidgetRenderProps` from The attribute shape (`Item`) is declared and exported once from `widget.ts`, alongside the `attributes`/`example` schema it describes; `render.tsx` imports it rather than redeclaring it, so the schema and the render props cannot drift. +If the host injects extra infrastructure fields through `attributes`, compose a +render-only type from the imported widget attribute type plus the host field type. +Do not re-declare the widget's own attribute shape in `render.tsx`. The host owns all chrome. The widget renders body only — never a `Card`, header, title, or remove control. `presentation` in `widget.json` declares how the widget wants to be framed; the host decides how. +## Two-component structure + +Every widget that reads dashboard state splits into two components: + +```tsx +// outer — receives host props, seeds WidgetRoot +import { + WidgetRoot, + type ReportParamsFieldAttributes, +} from '@jetpack-premium-analytics/widgets-toolkit'; +import type { WidgetRenderProps } from '@wordpress/widget-primitives'; +import type { MyAttributes } from './widget'; + +type MyWidgetRenderAttributes = MyAttributes & Partial< ReportParamsFieldAttributes >; + +export default function MyWidget( { + attributes = {}, +}: WidgetRenderProps< MyWidgetRenderAttributes > ) { + return ( + + + + ); +} + +// inner — reads dashboard context, does all data work +function MyWidgetInner() { + const { reportParams } = useWidgetRootContext(); + // ... +} +``` + +`useWidgetRootContext()` must be called inside a `` — calling it in the +outer component throws. `reportParams` always comes from context; the dashboard date +picker owns it. Never read date range from `attributes`. + +The outer component must still pass host `attributes` into ``. Do not +drop them just because the inner component only needs one widget setting like `max`; +otherwise host-provided `reportParams` and comparison controls are discarded. + diff --git a/.agents/skills/widget-audit.md b/.agents/skills/widget-audit.md index c2d989de79af..d5a04a8a5280 100644 --- a/.agents/skills/widget-audit.md +++ b/.agents/skills/widget-audit.md @@ -6,8 +6,11 @@ allowed-tools: Read, Glob, Grep, Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(ca Audit one `widgets//` against the widget contract and this repo's conventions. Report only — do not rewrite files unless the user asks. -**Argument**: `$ARGUMENTS` is a widget slug or path. If it resolves to a -`widgets//`, audit it; otherwise list the `widgets/` folder and ask which. +**Argument**: `$ARGUMENTS` is a widget slug or path. If it resolves to a registered +dashboard widget folder, audit it; otherwise list the `widgets/` folder and ask which. +A registered dashboard widget has `package.json`, `widget.json`, `widget.ts`, and +`render.tsx`. Presentational-only component folders under `widgets/` are out of scope +unless the user asks to convert them into registered widgets. The invariants live in the shared widgets rule (`.agents/rules/widgets.md`). This command is how to verify a widget against them, plus the specifics the rule cannot @@ -31,26 +34,50 @@ assume: namespace, text domain, and the dependency versions the package resolves `category`, `presentation` (`framed` | `content-bleed` | `full-bleed`). - `widget.ts` default-exports `title`, `icon`, and — when configurable — `attributes` + `example`. The attribute TS shape is declared once and reused. + `widget.ts` does NOT declare `presentation` — that field belongs only in `widget.json`. +- Every attribute declared in `widget.ts` is consumed somewhere in `render.tsx`; flag any + ghost attribute (declared but never read from `attributes`). - `render.tsx` default-exports the component. - `package.json` `dependencies` mirror the imports across the widget source: - nothing missing, nothing unused. + nothing missing, nothing unused. Internal packages use `@jetpack-premium-analytics/*` + aliases (never `@automattic/jetpack-premium-analytics-*` names). **Contract** -- `render.tsx` props are typed by `WidgetRenderProps` from - `@wordpress/widget-primitives`, not a hand-rolled shape. +- `render.tsx` props are typed by `WidgetRenderProps` from + `@wordpress/widget-primitives`, not a hand-rolled shape. `T` imports the widget's own + attribute type from `./widget`; it may compose that imported type with a host field + type such as `Partial`, but it must not re-declare the + widget attribute shape in `render.tsx`. - `attributes` is defended — the host may pass it empty or undefined, so default it. - The component receives only `{ attributes, setAttributes }`; no dashboard / surface / wp-admin imports, no `onRemove` / header / kebab. +- If the component wraps children in ``, the outer component passes host + attributes through as `` so injected + `reportParams` and comparison controls reach the context. + +**Stories** +- If stories expose `withComparison`, both the close-up and dashboard stories pass + `reportParams: getDefaultQueryParams(withComparison)` into the render component, and + the render component passes those attributes into ``. **Chrome** - The body renders content only — never a `Card`, header, title bar, or remove control (the host owns chrome). `presentation` in `widget.json` is a valid value. +- Verify that the `presentation` value in `widget.json` matches the widget's actual layout + assumptions (e.g. `full-bleed` vs `framed` changes whether the host renders a border and + padding). A mismatch causes incorrect chrome without a compile error. **Style + i18n** - Every `--wpds-*` token in the CSS exists in the resolved `@wordpress/theme` `design-tokens.css` (grep it; do not infer renamed names). Styles are CSS - Modules, never global CSS. -- User-visible strings go through `__( …, '' )`. + Modules, never global CSS. Production widget render files must not use inline + `style={{ … }}` props; story-only canvas wrappers may use inline sizing when + the style is not part of the shipped widget UI. +- Every `