# Widgets belong to applications, not to Forums

2026-08-08. Written after Ernest set a requirement for the applications that do
not exist yet.

> "When we get Pages, Blogs, Events, etc built they need widgets that can show
> on the forum index page as well as Downloads. We will tackle that before we
> cut over."

So every new application ships widgets, and those widgets have to be placeable
on other applications' pages — a Blog widget on the forum index, an Events
widget on Downloads. This note records what that actually requires, because it
is not a thing you add afterwards.

---

## What is true today, measured

Two areas already work everywhere, and this is better than it looked:

```
/forums     widget-area-index.bottom, widget-area-sidebar
/downloads  widget-area-sidebar
/           widget-area-sidebar
```

`sidebar` and `footer` render through `theme::layouts/main`, and Downloads and
Pages both use that layout. **A widget in the sidebar already appears on
Downloads.** Verified by moving one there and fetching all three pages.

The rest does not work, and there are two separate reasons.

### 1. Four of the six areas are forum-only

`Areas::AREAS` is a fixed list, and four entries name forum hooks:

| Area | Hook | Renders on |
|---|---|---|
| `sidebar` | `layout.sidebar` | everywhere |
| `footer` | `footer.start` | everywhere |
| `index.top` / `index.bottom` | `forum.index.*` | the forum index only |
| `forum.top` | `forum.show.top` | a forum only |
| `topic.top` | `topic.show.top` | a topic only |

**Downloads and Pages emit no widget hooks at all** — `grep '@hook'` across
both modules' templates returns nothing. There is no "above the file list", so
there is nowhere on Downloads for a widget to go except the sidebar.

The comment on `Areas` is right and should stay: an area nobody renders into is
the same trap as a menu zone nobody renders — it looks like a feature and does
nothing. The fix is not to loosen that rule. It is to let an application
**declare its own areas**, which it can only do honestly because it also owns
the template that renders them.

### 2. No application can contribute a widget type

`Widgets::TYPES` is a `const` in the widget module listing eleven kinds in two
groups, Forum and Content. There is no seam. "Blog needs a Latest Entries
widget" cannot be said today without editing a different module's constant.

Five applications are coming. Five applications editing one const in a sixth
module is how that const becomes the thing everybody is afraid to touch.

## This is the same defect the content-type registry already fixed

[2026-08-07-the-application-model.md](2026-08-07-the-application-model.md)
divided the codebase into System, applications and services, and said plainly
that **a service must not name an application**. It counted `widget` among the
six modules with genuine forum coupling — nine references — and predicted the
answer would be a registry.

That prediction is now due. `widget` is a service. It currently knows what a
forum is, ships forum-shaped areas, and hardcodes forum-shaped types. Its
manifest still describes it as "blocks placed into areas of the forum".

## The shape

Two registries, mirroring the content-type one, both owned by System and both
populated by applications in `register()` — 🚨 *not* `boot()`, as this note
first said. The widget module wires one hook listener per area during its own
boot, so an area registered in another module's boot is only listened to if
that module happens to boot first. Every module registers before any module
boots, which makes `register()` the only ordering-safe place:

- **Widget types.** An application registers the kinds it provides — key,
  label, group, and how to render one. `Widgets::TYPES` becomes the forum
  module's own registration plus Content's, not a list in the middle.
- **Widget areas.** An application registers the areas it renders, each with
  the hook it really emits. Downloads registers "above the file list"; Blog
  registers its index areas; Forums keeps the four it already has.

A widget type and an area are then independent: **any registered type can go in
any registered area.** That is what makes "a Blog widget on the forum index"
and "an Events widget on Downloads" fall out of the design rather than needing
a case each.

## The rule, decided by Ernest 2026-08-08

> "If the module is enabled then you can add the widgets anywhere. If the
> module is disabled then it doesn't show as an available widget."

Two decisions, and they are the whole of the availability model:

1. **Enabled means anywhere.** A registered type may go in any registered area.
   There is no compatibility matrix, no per-area allow-list, no "this widget
   only makes sense here". An Events widget can go above the file list on
   Downloads because somebody wanted it there, and that is the end of it.
2. **Disabled means not offered.** A type belonging to a switched-off module
   does not appear in the dock. Its areas go with it.

This kills the first of the open questions this note originally left, and it
kills it the right way round: a matrix of which type may sit in which area
would be a rule nobody can remember, enforced against a judgement the operator
is better placed to make than we are.

### The part the rule does not say, and the answer the codebase already gives

"Doesn't show as an available widget" governs the **dock** — what you can add.
It leaves open what happens to a widget of that kind **already placed** when
the module is later switched off, and that case is real: somebody puts a Blog
widget on the forum index in March and turns Blog off in June.

The application model already answered this and the answer holds here:
**disabling hides, uninstalling removes, data is never touched.** So:

- The placed widget **stops rendering**. It does not error, and it does not
  disappear from the database.
- Its row, title and settings survive untouched. Re-enabling Blog brings it
  back exactly as it was — the same promise dragging a widget to the tray makes.
- 🚨 **While arranging, it is still drawn, and marked.** This is the one place
  it must not simply vanish: a widget nobody can see is a widget nobody can
  remove, and the operator would be left with a row they cannot reach from the
  editor. The precedent is already in the renderer — `hidden_from_you` draws
  the sign-up prompt for an administrator who could never otherwise see it,
  for exactly this reason. A disabled-module widget gets the same treatment and
  a note saying why it is not showing.

Uninstalling the module is the other path, and it is the one that is allowed to
take the widget rows with it — because that is what uninstall means.

## Why this goes before the applications, not after

It is the same argument the application-model note made about the content-type
registry, and it was right then: *much cheaper to design at 17 modules than at
30.*

If the registries come after, five applications each need their widget types
hacked into another module's const and their areas added to another module's
fixed list — then all five get rewritten when the seam finally lands. If they
come first, each application registers its own and the work is already done as
a side effect of building it.

**Order:** the two registries land with or just before **Pages custom
databases**, which is the next application-shaped piece of work and the first
one that will want to place a record list somewhere.

## What this note does not decide

- **How a widget type declares its settings form.** Today `Settings::fromRequest`
  switches on type in the widget module — the same hardcoding, one layer down,
  and it has to move with the rest.
- **Whether `widget` keeps its `page` dependency.** It requires `page` today
  for block rendering, which is a real use, but it is worth re-asking once
  content types own their own rendering.

---

## Built 2026-08-08

Two registries in System — `WidgetTypes` and `WidgetAreas` — populated from
each module's `register()`, because every module registers before any module
boots and the widget module wires one hook listener per area during its own
boot. An area registered in `boot()` would be a coin toss on module order.

**Ownership went where the queries are.**

| Was | Now | Why |
|---|---|---|
| `Widgets::TYPES` const, 11 kinds | applications register | no seam existed at all |
| `Areas::AREAS` const, 4 forum hooks | applications register | Downloads had nowhere to put a widget |
| Renderer renders all 11 | Renderer places, applications draw | a service owned other modules' content |
| Who's online, Newest members, Sign-up = "Forum" | System, group **Community** | they read `users` and nothing else |
| Statistics, Recent topics, Top posters | Forums | they count posts |
| Text/Heading/Image/Video/HTML | Pages | they are page blocks |
| — | Downloads: 2 kinds, 3 areas | the first application to add kinds Forums never heard of |

🚨 **The Renderer no longer takes a database connection.** That is the clearest
evidence the split is right: a class whose job is drawing a panel round some
markup had six queries in it.

### Verified end to end, not just unit-tested

- A **Newest files** widget on the forum index, linking to real files — and
  `/downloads/file/{slug}`, not an id-slug pair. Guessing that wrong is what
  put a 404 behind every download in search once already.
- A **Recent topics** widget on the Downloads index, in the new area.
- Downloads switched **off**: `/downloads` 404s, `/forums` still serves, its
  kinds vanish from the dock, its areas vanish from the page, and the widget
  already placed on the forum index renders **nothing** — no error, no empty
  panel.
- While arranging with Downloads off, that widget is **still drawn**, carries
  the orphan note, and has its remove X.
- Switched back on: the widget returns exactly as it was.

**502 tests**, up from 494. The new ones cover both refusals (an area with no
hook, a kind with no renderer), the duplicate-hook clash, ownership of areas
and kinds, the orphan rendering nothing, the orphan staying reachable while
arranging, and one broken kind not taking the area down with it.

### Still open

`Settings::fromRequest` still switches on type inside the widget module — the
same hardcoding one layer down, and the reason a Downloads widget cannot yet
offer a category picker the way Recent topics offers a forum picker. It is the
next thing to move, and it is why the two new kinds take a `category_id`
setting that nothing can currently set.
