Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion .agents/rules/widgets.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,70 @@
# 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<Item>` from
`@wordpress/widget-primitives`: it receives only `{ attributes, setAttributes }`.
`attributes` may arrive empty — default it (`= {}`).

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 (
<WidgetRoot attributes={ attributes }>
<MyWidgetInner />
</WidgetRoot>
);
}

// inner — reads dashboard context, does all data work
function MyWidgetInner() {
const { reportParams } = useWidgetRootContext();
// ...
}
```

`useWidgetRootContext()` must be called inside a `<WidgetRoot>` — 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 `<WidgetRoot>`. 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.

<!-- TODO: link to the canonical widget API declaration (contract types). -->
41 changes: 34 additions & 7 deletions .agents/skills/widget-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ allowed-tools: Read, Glob, Grep, Bash(grep:*), Bash(find:*), Bash(ls:*), Bash(ca
Audit one `widgets/<slug>/` 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/<slug>/`, 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
Expand All @@ -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<Item>` from
`@wordpress/widget-primitives`, not a hand-rolled shape.
- `render.tsx` props are typed by `WidgetRenderProps<T>` 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<ReportParamsFieldAttributes>`, 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 `<WidgetRoot>`, the outer component passes host
attributes through as `<WidgetRoot attributes={ attributes }>` 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 `<WidgetRoot>`.

**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 `__( …, '<text-domain>' )`.
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 `<button>` element has an explicit `type` attribute (`type="button"` for non-submit
actions; `type="submit"` only when the button is intentionally a form submit).
- User-visible strings go through `__( …, '<text-domain>' )`. Check data-transform and
display files, not just the render component.
- All source code comments are in English.

**Docs (JSDoc)**
- Props are a named `type`/`interface` with each field documented on the type,
Expand Down
145 changes: 145 additions & 0 deletions projects/packages/premium-analytics/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ prefixes; Woo `analytics/reports/*` → `proxy/v2/analytics/reports/*`. The dash
- `v2` vs `v1.x` changes the WPCOM base — a wrong version silently hits a different endpoint.
- Sync code under `src/Sync/` is interim (WOOA7S-1550); don't build on it.
- Don't edit dashboard React in Calypso — it lives here now.
- Internal package names use `@jetpack-premium-analytics/*` aliases throughout the package —
never `@automattic/jetpack-premium-analytics-*`.
- All source code comments must be in English.

## Widgets

Expand All @@ -121,6 +124,11 @@ lazy-loaded by the dashboard at runtime.
> Do not use them as templates for new work — follow the structure and story template below
> instead.

These rules apply to registered dashboard widgets: folders with `package.json`,
`widget.json`, `widget.ts`, and `render.tsx` that the dashboard can lazy-load.
Presentational-only component folders under `widgets/` are out of scope unless they
are being converted into registered widgets.

### REQUIRED: widget folder structure

Each new widget MUST ship as a self-contained folder with these files:
Expand All @@ -145,6 +153,53 @@ Notes:
`link:` for internal packages (e.g.
`"@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit"`).

### REQUIRED: render component contract

The render component receives only widget host props. Type it with
`WidgetRenderProps<T>` from `@wordpress/widget-primitives`, default `attributes`, and pass
host-provided attributes into `<WidgetRoot>`. This is how Storybook and the dashboard inject
`reportParams` for date range and comparison state.

```tsx
import {
WidgetRoot,
type ReportParamsFieldAttributes,
} from '@jetpack-premium-analytics/widgets-toolkit';
import type { WidgetRenderProps } from '@wordpress/widget-primitives';
import type { MyWidgetAttributes } from './widget';

type MyWidgetRenderAttributes =
MyWidgetAttributes & Partial< ReportParamsFieldAttributes >;

export default function MyWidget( {
attributes = {},
}: WidgetRenderProps< MyWidgetRenderAttributes > ) {
return (
<WidgetRoot attributes={ attributes }>
<MyWidgetInner max={ attributes.max } />
</WidgetRoot>
);
}
```

The widget's own attribute shape is declared and exported once from `widget.ts`,
alongside the `attributes`/`example` schema it describes. `render.tsx` imports that type;
it may compose a render-only type with host fields like `Partial<ReportParamsFieldAttributes>`,
but it must not re-declare the widget's own attributes.

Dashboard state is read inside the component wrapped by `<WidgetRoot>`:

```tsx
function MyWidgetInner( { max }: { max?: number } ) {
const { reportParams } = useWidgetRootContext();
// Fetch data with hooks that accept reportParams.
}
```

Do not call `useWidgetRootContext()` in the outer render component before `<WidgetRoot>`
exists, and do not read the dashboard date range directly from `attributes` in the inner
component.

### REQUIRED: Storybook story for every widget

Every widget MUST have a Storybook story alongside it. New widgets without a story should
Expand Down Expand Up @@ -294,9 +349,99 @@ selector) as extra fields on the controls interface plus matching `args` and `ar
shared dashboard helper already provides container width / edit-mode / host-environment
controls, so there's no need to add custom size decorators per widget.

If a story exposes `withComparison`, both the close-up story and the dashboard story must pass
`reportParams: getDefaultQueryParams( withComparison )` into the render component, and the render
component must pass those attributes into `<WidgetRoot>`. A visible Storybook control that is not
wired into the render/data flow gives reviewers a false comparison test.

### Widget pitfalls

- Putting new widgets under `packages/widgets-toolkit/src/widgets/*` — that path is for the
legacy widgets that haven't been migrated yet.
- Using the legacy `withWidgetRoot()` decorator for new stories — new widgets render via the
real `WidgetDashboard` through the shared story helper instead.
- Declaring `presentation` in `widget.ts` — `widget.json` is the source of truth for that
field; omit it from `widget.ts` entirely.
- Re-declaring the attribute type in `render.tsx` — the shape is declared once in `widget.ts`
and imported in `render.tsx`; render-only types may compose that imported shape with host
fields like `Partial<ReportParamsFieldAttributes>`, but must not duplicate the shape.
- Dropping `attributes` at the `<WidgetRoot>` boundary — this discards host-provided
`reportParams` and makes date/comparison Storybook controls misleading.
- Writing `<button>` without an explicit `type` — the HTML default is `type="submit"`, which
can fire accidental form submissions. Use `type="button"` for non-submit actions.
- Do not use inline `style={{ … }}` props in production widget render files — all widget
styles belong in the widget's CSS Module. Story-only canvas wrappers may use inline
sizing when the style is not part of the shipped widget UI.
- Reimplementing a utility that already exists in `widgets-toolkit` (e.g. `flagUrl`) — check
`packages/widgets-toolkit/src/helpers/` before writing a new one.

### Stats widgets

Ports of Jetpack Stats modules into the dashboard follow a fixed pattern. Read this
before writing any Stats widget — many mistakes here are silent at build time.

**Data layer**

`packages/data/` already has a typed hook for every Stats module (`useStatsTopPosts`,
`useStatsSearchTerms`, `useStatsLocations`, `useStatsDevices`, …). Look there first —
do not call `fetchStatsProxy` or `apiFetch` directly from a widget.

Each hook returns `{ primary, comparison, isLoading, isError, … }`. For the standard
leaderboard/list widgets, reach data through:

```ts
const report = primary.data as StatsNormalizedReport< StatsXxxItem > | undefined;
const items = report?.data?.[ 0 ]?.items ?? [];
```

Date-range conversion (`from`/`to` → `period`/`end_date`/`days`) is handled inside
the query factory — do not do it in the widget or the view hook.

**`max` semantics**

`max = 0` means "all rows". Use `slice( 0, max > 0 ? max : undefined )`, never
`slice( 0, max )` (the latter returns an empty array when `max` is 0).

**Loading and stale data**

Show `<WidgetLoadingOverlay />` only when there is no data yet:
`isLoading && data.length === 0`. When stale data exists, pass `loading={ isLoading }`
to the chart component so an in-place spinner appears without hiding the rows.

**Comparison data**

Stats hooks built on `useStatsReport()` return `{ primary, comparison, hasComparison, ... }`.
When `reportParams` includes `comp=1`, `compare_from`, and `compare_to`, the data layer fetches
the comparison period automatically.

Widgets still need to map comparison rows into chart data explicitly. For leaderboard/list
widgets, build a lookup from `comparison.data?.[ 0 ]?.items` using the same stable key used for
the primary row (post ID/URL, country code, search term, device key, etc.), then set
`previousValue`, `previousShare`, and `delta` from the matched comparison row. Do not assume
primary and comparison arrays have the same order or the same rows.

Using `previousValue: 0` and `delta: 0` as placeholders is only acceptable when the chart
comparison UI is disabled (`withComparison={ false }` or omitted). Do not expose a
`withComparison` story/control as meaningful until the widget maps `comparison.data` into real
previous-period values.

**Visual conventions**

- Widget title: `<Text variant="heading-md" render={ <h3 /> }>`
- View count format: `dataFormat={ { type: 'number', options: { useMultipliers: true, decimals: 0 } } }`
- Leaderboard row height: custom labels should produce a stable 36px row height. For the common
`<Text>` label case, `padding: var(--wpds-dimension-padding-sm)` is enough when the text
line-height plus vertical padding yields 36px. Use `min-height: 36px` when the label content
or typography does not naturally produce that height.
- Empty state: pass `emptyStateText` to `LeaderboardChart` — do not add a separate
`data.length === 0` render branch in the widget.
- Widget picker preview: add this to the CSS Module so the preview tile renders at a
sensible aspect ratio instead of collapsing:

```css
:global( [inert]:not( [inert='true'] ) ) .root {
height: auto;
aspect-ratio: 4 / 3;
overflow: hidden;
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Update widget conventions and Stats porting documentation.
Loading