# `MishkaCmsCore.Dashboard.Widget`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/dashboard/widget.ex#L5)

Admin dashboard widgets — the whole concern in one module: the validated descriptor every widget
declares, collecting widgets (core + plugins), filtering them by the viewer's permission, arranging
them by a per-user layout, and rendering one as a card.

A widget is a plugin. An extension ships a `MishkaInstaller.Event.Hook` on the `"dashboard:widgets"`
event whose `call/1` appends a descriptor built with `builder/1`; enabling/disabling and default
ordering reuse the plugin lifecycle (`priority`, `Event.start`/`Event.stop`). This is the sibling of
`MishkaCmsCore.Settings`' "register via hook" pattern — except widgets aren't cached, the hook is
called at mount so an enabled/disabled plugin shows up immediately.

`component` is a `{module, function}` tuple (a function component, the default) or a LiveComponent
module with `type: :live_component` — never a closure, so the descriptor passes safely through the
hook payload (which is plain-map only).

Two audiences: developers *register* widgets (priority = default order, `size` = default grid
width, `default_hidden: true` = available-but-off, `config` = the settings the widget lets a user
change); an admin *arranges and tunes* their own.

A widget declares its priority twice and they are NOT the same number. The descriptor's `priority`
is the board order and may be any integer — `collect/1` sorts by it. The plugin's
`initial: %{priority: n}` is mishka_installer's registration priority, which it validates as
**1..100**; a plugin declaring more than 100 is rejected outright and its widget never appears.
Keep them mirrored where the range allows, and clamp the plugin one when it does not.

Arrangement lives in a `layout` map the page persists per user — drag order (`"order"`),
hidden/added ids and per-widget width overrides (`"spans"`, read through `span/2`). **Settings do
not.** Each declared setting is its own per-user row in `MishkaCmsCore.Settings`, keyed by
`setting_key/2`, written by `set_config/4` and read by `config/3`. That is what lets the owning
extension register its widgets' settings in ITS OWN settings tab
(`settings_definitions/1` + a `settings:register` plugin such as
`MishkaBlog.Dashboard.WidgetSettings`) — a blog widget's "Posts to show" is documented under
**Blog**, not buried in the dashboard's blob. See
`MishkaCmsCoreWeb.Live.Admin.Dashboard.Home.Index`.

A `config` entry is a plain map — the descriptor travels through the hook payload, which is
plain-map only — declaring ONE setting, in one of exactly three types:

    config: [
      %{key: "count", type: :integer, label: "Posts to show", default: 6, min: 1, max: 12},
      %{key: "show_avatars", type: :boolean, label: "Show avatars", default: true},
      %{key: "sort", type: :select, label: "Sort by", default: "recent",
        options: [{"Most recent", "recent"}, {"Most viewed", "viewed"}]}
    ]

Anything else is skipped rather than raised on, so a descriptor from an extension can never take
the board down. A widget declaring `config` should also name the settings tab those settings
belong in — `settings_category: "blog"` — which is a DIFFERENT field from the descriptor's
`category` (the widget's own grouping label) and falls back to it when unset.

Widgets share one visual language: `card/1` renders the header (descriptor icon + title + an
optional "View all" `link`), and widget bodies compose `badge/1`, `stat/1`, `empty/1` and
`relative_time/1` so every extension's widgets look native. Data follows the tenant contract:
`load/1` receives the viewing user and derives its scope from `scope/1` — a master (no
`site_id`) sees every site's data, a tenant admin only its own site's. A widget with settings
exports `load/2` instead, the same user plus its effective `config/2` map; the dashboard prefers
`load/2` when it is exported, so a widget without settings keeps `load/1` untouched.

# `t`

```elixir
@type t() :: %MishkaCmsCore.Dashboard.Widget{
  category: String.t() | nil,
  component: tuple(),
  config: list() | nil,
  default_hidden: boolean() | nil,
  description: String.t() | nil,
  icon: String.t() | nil,
  id: String.t(),
  link: String.t() | nil,
  permission: any() | nil,
  priority: integer() | nil,
  pubsub: list() | nil,
  settings_category: String.t() | nil,
  size: integer() | nil,
  title: String.t(),
  type: atom() | nil,
  variant: atom() | nil
}
```

# `add`

```elixir
@spec add(map(), String.t()) :: map()
```

Puts a widget back on the dashboard (un-hides it or reveals a default-hidden one).

# `add_at`

```elixir
@spec add_at(map(), [t()], String.t(), non_neg_integer()) :: map()
```

Adds a widget and places it at `index` among the shown widgets (a drop from the library).

# `arrange`

```elixir
@spec arrange([t()], map()) :: [t()]
```

Orders and filters widgets for display. With no layout, widgets keep their registered order and
`default_hidden` ones are dropped. A `layout` of `%{"order" => [id], "hidden" => [id], "added" =>
[id]}` (the per-user preference) overrides: `hidden` ids are removed, `added` reveals a
default-hidden widget, and what remains is sorted by `order` (unknown ids keep their position).
The layout's `"spans"` key does not affect arrangement — `span/2` reads it for widget width.

# `available`

```elixir
@spec available([t()], map()) :: [t()]
```

Permitted widgets the user has NOT placed on the dashboard — the "add a widget" choices.

# `badge`

A small tonal badge for statuses and actions. Tones are the activity feed's `badge_tone` atoms
(`:emerald`, `:sky`, `:amber`, `:rose`, `:indigo`, `:violet`, anything else = slate).

## Attributes

* `label` (`:string`) (required)
* `tone` (`:atom`) - Defaults to `:slate`.

# `builder`

# `card`

Renders one widget as a card from its async `state` — `:loading` (a skeleton), `{:error, _}` (a
placeholder), or `{:ok, data}` (the widget's `render/1` fed the loaded `data`). The dashboard loads
each widget's data off the render (a Task per widget), so no widget blocks first paint; a widget
whose LOAD fails degrades to the placeholder instead of taking the board with it.

A widget whose RENDER raises is not contained here, and `render_loaded/2` says why.

# `card_footer`

The card footer — a bottom-right "{label} →" link, drawn only when there is a `link` to follow
(`collect/1` clears one the viewer may not open). `border: true` adds the rule above it, set off
from the body by a 16px margin (React's spacing); without it the link just clears the body by 14px.
Spacing is explicit, not `mt-auto`: the board grid is `items-start`, so a card is only ever as tall
as its content and there is no free space for `auto` to push against.

## Attributes

* `link` (`:string`) - Defaults to `nil`.
* `label` (`:string`) - Defaults to `nil`.
* `border` (`:boolean`) - Defaults to `false`.

# `card_header`

A widget card header — the tinted icon chip + title (+ optional subtitle) on the left, and an
optional `:right` slot (a status pill/badge). `tone` colours the chip: `:violet`, `:green`,
`:gray`, `:amber`, `:red`, `:blue`.

## Attributes

* `icon` (`:string`) (required)
* `tone` (`:atom`) - Defaults to `:gray`.
* `title` (`:string`) (required)
* `subtitle` (`:string`) - Defaults to `nil`.
## Slots

* `right`

# `col_span_class`

```elixir
@spec col_span_class(pos_integer()) :: String.t()
```

Tailwind `col-span-*` for the 6-column dashboard grid; collapses to full width under 980px.

WHOLE CLASS NAMES, one clause per width. Written as `"col-span-#{n} …"` the span was invisible to
Tailwind's scanner, and every rule it happened to have came from some unrelated file that spells
the same class out — so the dashboard's column widths were held up by coincidence. `mix admin_css`
compiles the admin from source text now; a class assembled at render time has nothing to find.

# `collect`

```elixir
@spec collect(map() | nil) :: [t()]
```

Widgets the user may see — every plugin registered on the `"dashboard:widgets"` event, sorted by
each descriptor's `priority` (low → high, `id` as tiebreaker), filtered by permission. A widget
whose `link` leads to a page the user may not open (`MishkaCmsCore.Sidebar.opens?/2`) comes back
with no `link`, so its card draws no footer. Returns `[]` when no plugin has registered yet.

Order is taken from the descriptor's own `priority`, not the plugin's registration order: the
compiled hook chain is fixed at registration time from the priorities persisted in the event
registry, so a source-level priority change would not re-order an already-registered plugin. Sorting
the returned descriptors here keeps the default board order fully deterministic and source-driven.

# `config`

```elixir
@spec config(t(), map() | nil, map()) :: map()
```

A widget's effective settings for one viewer, `%{"key" => value}` — every declared setting read
from its own per-user row (`setting_key/2` through `MishkaCmsCore.Settings.get_user/3`), falling
back to the value the widget declared. EVERY declared key is present, so a `load/2` never has to
guess a default, and every value is re-validated on the way out: a row hand-edited from the
settings page, or one left behind by an older declaration, can't feed a widget something its own
`config` doesn't allow. A widget that declares nothing — or a caller with no user — gets `%{}`.

`layout` is the LEGACY per-user dashboard blob (`layout["config"][widget id]`), where these values
lived before they moved into Settings. It is read-only here and consulted only when the per-user
row is unset, so an admin who tuned a card before this change keeps seeing their number; the first
save through the gear (or the settings page) writes the real row and the blob stops mattering.
Nothing is written back to it, and nothing new is ever written into it.

# `config_control`

The per-widget settings dropdown shown in customize mode beside `span_control/1` — a gear that
opens a panel of the widget's declared `config`, one input per setting. Nothing renders for a
widget that declares none, or for a declaration of an unsupported type.

Opening is pure client state (`JS.toggle`, dismissed by a click away): whether a dropdown is open
is not worth a round trip. The panel carries `phx-update="ignore"` so it belongs to the client
once rendered — re-rendering the card (which every change does, the widget re-loads) would
otherwise strip the toggled `display` back off and shut the panel mid-edit.

Each change posts `save_widget_config` with the widget id, which the page re-validates through
`set_config/4` before re-running just that widget's load.

A number field is bounded on the client by `NumberBounds` as well as on the server, because an
ignored element is never patched: a value the server clamps would otherwise stay on screen for the
rest of the session while the card showed the clamped one.

## Attributes

* `id` (`:string`) (required)
* `settings` (`:list`) (required)
* `values` (`:map`) - Defaults to `%{}`.

# `empty`

A consistent empty state for list widgets.
## Attributes

* `icon` (`:string`) - Defaults to `"hero-inbox"`.
* `message` (`:string`) (required)

# `enforce_keys`

# `enforce_keys`

# `example`

A sample %MishkaCmsCore.Dashboard.Widget{} populated from defaults + type-based fallbacks.

# `hide`

```elixir
@spec hide(map(), String.t()) :: map()
```

Removes a widget from the dashboard.

# `keys`

# `keys`

# `layout`

```elixir
@spec layout(map()) :: map()
```

The user's saved dashboard layout (`%{}` when they've never customized).

# `permitted?`

```elixir
@spec permitted?(t(), map() | nil) :: boolean()
```

Whether `user` may see this widget — its descriptor's `permission`, which is one name or a list of
them where holding ANY is enough, checked the same way a setting definition's is
(`MishkaCmsCore.Settings.permitted?/2`). This is the filter `collect/1` applies, exposed because
it is also the question the dashboard's own templates ask about a single card.

# `pill`

A status pill for card headers — tinted bg + colour, optional pulsing live dot.
## Attributes

* `label` (`:string`) (required)
* `tone` (`:atom`) - Defaults to `:gray`.
* `dot` (`:boolean`) - Defaults to `false`.

# `relative_time`

```elixir
@spec relative_time(DateTime.t() | term()) :: String.t()
```

Compact "how long ago" label for feed rows, in the reader's language — "now", "12m ago", "3h ago", then the day, "Jul 3".

# `reorder`

```elixir
@spec reorder(map(), [String.t()]) :: map()
```

Reorders the dashboard to the dragged id list (the client's DOM order).

# `reset_config`

```elixir
@spec reset_config(map(), t()) :: :ok
```

Puts every one of a widget's settings back to the value it declared, for this user — what the
dashboard's "Reset" does to settings now that they outlive the layout blob it clears. Writes each
default through `set_config/4`, so a default is coerced and bounded like any other value.

# `save_layout`

```elixir
@spec save_layout(map(), map()) :: {:ok, map()} | {:error, term()}
```

Persists the user's dashboard layout.

# `scope`

```elixir
@spec scope(map()) :: String.t() | nil
```

The viewing user's data scope: `nil` = master (sees every site), a uuid = that tenant's own site
only. Every widget's `load` derives its queries from this.

The viewing user is REQUIRED. It used to be optional, and a missing one returned `nil` — the exact
value a master returns — so every widget's `scope_filter(query, nil)` applied no filter and a
caller that had lost track of its user quietly read every site's data instead of failing. A master
is a user whose `site_id` is `nil`, never the absence of a user, and the two must not collapse
into one value on the way into a query. Anything else raises: on a boundary like this a crash is
the safe outcome and a silent full-fleet read is not.

# `set_config`

```elixir
@spec set_config(map(), t(), String.t(), term()) :: {:ok, term()} | :error
```

Saves one of a widget's settings FOR THIS USER, validated against the widget's own declaration:
the posted value is coerced to the declared type — an integer parsed and clamped to `min`/`max`, a
boolean, one of a select's declared options — and only then written to that setting's own per-user
row (`setting_key/2` through `MishkaCmsCore.Settings.put_user/3`, which namespaces the key with
the user's id, so two admins can never see each other's value). A key the widget never declared, a
type outside the supported three, or a value that can't be coerced writes nothing at all. This is
the boundary: nothing reaches a widget's `load/2` that its own declaration didn't describe.

Returns `{:ok, stored_value}`, or `:error` for anything refused and for a write that failed.

# `set_span`

```elixir
@spec set_span(map(), String.t(), pos_integer()) :: map()
```

Sets the user's width override for a widget, in grid columns (of 6): 2=⅓, 3=½, 4=⅔, 6=full.

# `setting_key`

```elixir
@spec setting_key(t() | String.t(), String.t()) :: String.t()
```

The `MishkaCmsCore.Settings` key one of a widget's settings is stored under —
`"dashboard.widget.<widget id>.<setting key>"`. This is the single source of that shape: the
extension plugin that REGISTERS the definition (`settings_definitions/1`) and the code that reads
it (`config/3`) both derive the key here, so the two can never drift apart. The key carries no
user — `MishkaCmsCore.Settings.put_user/3` namespaces it per user on the way in.

# `settings_definitions`

```elixir
@spec settings_definitions(t()) :: [MishkaCmsCore.Settings.definition()]
```

This widget's declared settings as `MishkaCmsCore.Settings` definitions — what an extension's
`settings:register` plugin appends so each widget's settings are documented in ITS OWN tab
(`settings_category`, falling back to the descriptor's `category`) under one **Dashboard widgets**
group, instead of living nowhere near the extension that owns them.

Every definition is `scope: :user` and `source: :plugin`, and inherits the widget's `permission`,
so an admin who cannot see the widget never sees its settings either. `:user` settings ARE
editable on the settings page (it reads and writes them through `Settings.get_user/3` /
`Settings.put_user/3`) — the very rows the card's gear writes — so the two editors agree. The
declared `min`/`max` travel with the definition, so a number typed on the settings page is bounded
by `MishkaCmsCore.Settings.cast/2` exactly as one picked in the card's gear is; `config/3` still
re-validates on the way out, which is what catches a row left behind by an older declaration.

A declaration of an unsupported type is skipped, like everywhere else.

# `span`

```elixir
@spec span(t(), map()) :: pos_integer()
```

A widget's effective width in grid columns — the user's override or the registered `size`.

# `span_control`

The per-widget width picker shown in customize mode — sets the card's grid width to a third, two
thirds, or the full row (2/4/6 of the 6-column grid) via `set_span`. The active width is highlighted
from the widget's current `span`.

## Attributes

* `id` (`:string`) (required)
* `span` (`:integer`) - Defaults to `nil`.

# `stat`

A stat tile — icon + label over a large tabular number.
## Attributes

* `label` (`:string`) (required)
* `value` (`:any`) (required)
* `icon` (`:string`) - Defaults to `nil`.
* `tone` (`:atom`) - Defaults to `:slate`.

# `topics`

```elixir
@spec topics(t(), map() | nil) :: [String.t()]
```

The PubSub topics whose broadcasts make this widget's `load/1` stale — the dashboard subscribes to
them and re-loads just that widget, so a board is live without polling.

Each `pubsub` entry is an Ash resource (its topics come from its own `pub_sub` prefix), a module
exposing `topics/1` (an activity `Source`), or a literal topic string. Scoped exactly like `load/1`:
a master watches the global topics, a tenant admin also watches its own site's. A resource that
doesn't publish contributes nothing.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
