# ADR-010: Frontend plugin hook contract

- **Status:** ACCEPTED
- **Date:** 2026-07-11
- **Accepted:** 2026-07-11
- **Deciders:** cproudlock
- **Supersedes:** none

## Context

The backend plugin contract is settled (ADR-002 surface, ADR-006 collector,
ADR-008 migrations, ADR-009 route gating). The one undefined piece before 1.0 is
the frontend: how a plugin contributes UI without hand-editing core Vue files.
CONTRACT-STABILITY.md names this the single biggest churn item ("no server-side
hook for asset-detail panels, map markers, or search-result rendering; a plugin
that needs custom UI still hand-edits the Vue frontend").

The `measuringtools` plugin (bundled 2026-07-11, whose construction is narrated
in docs/PLUGIN-GUIDE.md) is the fresh evidence. Integrating it needed exactly
**two hand edits to core frontend files**:

1. `frontend/src/api/index.js` - an api-client block appended after
   `warrantyApi` (PLUGIN-GUIDE.md section 9). The scaffolder now emits a
   paste-in snippet, but it is still a hand edit to a shared, churn-heavy file.
2. `frontend/src/views/settings/settingsNav.js` - a card entry in the hardcoded
   `settingsGroups` catalog (the "Measuring Tools" group, ~line 51), consumed by
   `SettingsLayout` (left rail) and `SettingsIndex` (landing overview).

Everything else integrated with **zero** core edits, because a mechanism already
existed for it:

| Capability | Existing mechanism |
|---|---|
| Views / routes | Auto-discovery: `frontend/src/router/index.js` globs `router/routes/*.js` via `import.meta.glob`; a route starting `settings/` auto-nests under the settings shell. |
| Disabled-plugin gating | `meta: { plugin: '<name>' }` per ADR-009; router guard redirects when the backend plugin is off. |
| Sidebar / dashboard / reports | Declarative backend hooks rendered by core: `get_navigation_items`, `get_dashboard_widgets`, `get_reports` on `BasePlugin` (shopdb/plugins/base.py), merged by `shopdb/core/api/dashboard.py` and `shopdb/core/api/reports.py`. |
| Map placement | Data-driven off asset types + resolved positions (ADR-001); a typed, positioned asset appears on the map with its type color and no plugin-side map code. |

Capabilities a plugin **cannot have at all today** - no hook exists, and adding
one would mean forking a core view:

3. **Asset-detail extension panels.** A warranty-coverage section on a PC or
   printer detail page, a calibration-status card on a measuring tool. Today
   `WarrantyPanel.vue` is composed in by hand-editing each detail view.
4. **Map marker / overlay contributions.** A calibration-due badge on the
   shop-floor map (`frontend/src/views/MapView.vue`). The map draws type
   colors but has no plugin decoration path.
5. **Search-result rendering / routing for plugin asset types.** Global search
   returns assets, but core hardcodes how each type renders and where its detail
   link points; a plugin asset type has no way to declare its icon or route.

There is also an unsolved distribution wrinkle from ADR-003: external plugins
symlink into `plugins/<name>/` backend-only, but any frontend file they carry
must be physically copied into `frontend/src/`, because Vite compiles the tree
at build time and cannot reach outside it.

## Options considered

### A. Runtime dynamic component registration

Plugins ship real Vue components that core loads and mounts at runtime into
named extension points (an `iconMap` registration, a panel registry, a marker
renderer registry). This is the richest model and the one ADR-009's "Future
direction" sketched.

- Pro: a plugin can render anything; no core generic-renderer ceiling.
- Con: requires runtime module loading of plugin-authored code (dynamic import
  of built chunks), a versioned shared-component surface that becomes an
  accidental contract the moment it leaks, and it does nothing about the
  build-time-only reach of Vite for external plugins. It is the most code for
  the least near-term payoff. ADR-009 already priced this and deferred it.

### B. Declarative data-only hooks rendered by core generic components

Plugins return plain dicts from new `BasePlugin` hooks; a generic core component
renders them. This is the exact precedent already proven three times:
`get_navigation_items`, `get_dashboard_widgets`, and `get_reports` (0.6.0) all
return dicts that a core consumer endpoint merges and a core Vue component
renders. No plugin ships frontend code.

- Pro: additive, minor-bump changes under ADR-002; identical access pattern to
  the existing consumers (skip disabled, fail-loud in dev, isolate in prod);
  works untouched for external symlink-only plugins, because the data crosses
  the wire and core owns the renderer.
- Con: bounded to what a generic renderer can draw. A panel that needs a bespoke
  chart or a custom-interaction map overlay does not fit.

### C. Build-time file-convention discovery

Extend the `import.meta.glob` precedent from `./routes/*.js` to a plugin-owned
frontend tree (`plugins/*/frontend/`), so a plugin's real components (full
list/detail/form views, its api-client module) live with the plugin and the
build picks them up by convention. This is ADR-009 "Future direction" steps 1-2.

- Pro: true self-containment for genuine components; the natural home for the
  api-client block (friction 1) and full views.
- Con: significant build-system work (per-plugin Vite entry discovery,
  code-splitting, dev-server HMR across the tree) and it does not by itself
  solve the external-plugin wrinkle - a symlinked out-of-tree `frontend/` is
  still outside Vite's compiled root. Only pays off once out-of-tree plugins
  with frontends are a real requirement.

### Hybrid

The evidence splits cleanly. The five friction points fall into two buckets:
surfaces where a generic renderer fed by plugin data is sufficient (2, 3, 4, 5),
and surfaces where a real component is genuinely unavoidable (full views, and
the api-client module in friction 1). Option B fits the first bucket exactly and
is cheap and additive. Option C is the right long-term answer for the second but
is expensive and, per ADR-009, gated on external-plugin demand. Option A buys
nothing B does not, at the highest cost. The decision is B now, C deferred, A
not pursued.

## Decision

**DECISION:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
the four presentation surfaces a generic core renderer can serve, and keep
**file-convention glob discovery** (Option C) as the deferred mechanism for the
residual cases where a real component is unavoidable. Do not pursue runtime
component registration (Option A).

### New hooks (Option B, data-only, additive)

Each is a new optional `BasePlugin` method returning a list of dicts, merged by
a core consumer endpoint using the same pattern as `get_reports` (inject
`plugin` name, skip disabled, re-raise in dev/test, log-and-isolate in prod),
and rendered by a generic core component. Icon values are string keys mapped to
Lucide components core-side, exactly like `get_navigation_items`.

**`get_settings_cards`** - resolves friction point 2 (settingsNav.js hand edit).

```
{
  'group':       'Measuring Tools',           # rail group title (created if new)
  'to':          '/settings/measuringtooltypes',
  'icon':        'ruler',                      # string key, mapped core-side
  'title':       'Measuring Tool Types',
  'description': 'Manage measuring-tool subtypes + map colors',
  'position':    22,                           # order within the group
}
```

Consumer: a new `GET /api/settings/cards` merges enabled plugins' cards into the
core `settingsGroups` catalog; `SettingsLayout` and `SettingsIndex` read the
merged catalog instead of the hardcoded JS array. The catalog's own core groups
stay in `settingsNav.js`; plugin groups are appended.

**`get_asset_panels`** - resolves friction point 3 (asset-detail panels).

```
{
  'id':         'calibration',
  'title':      'Calibration',
  'assettypes': ['measuring_tool'],           # detail pages it appears on; ['*'] = all
  'endpoint':   '/api/measuringtools/{assetid}/calibration-panel',
  'render':     'keyvalue',                    # 'keyvalue' | 'table' | 'badge'
  'position':   20,
}
```

Consumer: `GET /api/assets/{assetid}/panels` returns the panels whose
`assettypes` match that asset's type; a generic `AssetPanel` component on the
detail page fetches each `endpoint` and renders it in the declared style. This
covers the warranty-coverage and calibration-status cases. A panel that needs
bespoke UI (a chart) is out of scope for the data-only hook and falls to the
deferred component mechanism below - stated honestly, not hidden.

**`get_map_overlays`** - resolves friction point 4 (map marker/overlay badges).

```
{
  'id':       'calibration-due',
  'label':    'Calibration due',              # legend label
  'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
  'style':    'badge',                        # 'badge' | 'ring'
  'legend':   True,
}
```

Consumer: `MapView.vue` fetches enabled plugins' overlay endpoints and decorates
the already-placed markers; legend entries append to the existing legend. The
map stays data-driven; plugins add decoration data, not map code.

**`get_asset_presentation`** - resolves friction point 5 (search rendering /
routing) and, as a bonus, removes the `AppLayout.vue` iconMap hand edit called
out in PLUGIN-GUIDE.md section 9.

```
{
  'assettype': 'measuring_tool',              # AssetType.assettype key the plugin owns
  'icon':      'ruler',
  'label':     'Measuring Tool',
  'route':     '/measuringtools/{assetid}',   # detail-route pattern
}
```

Consumer: `GET /api/assets/presentation` returns the type-to-presentation map;
global-search result rows and any asset cross-link use it to pick the icon and
build the detail link, so core never hardcodes a plugin's route or icon.

### Friction map

| Friction point | Mechanism | Hook / change |
|---|---|---|
| 1. api-client block in api/index.js | C (deferred) | plugin-owned `frontend/` tree, glob-discovered; scaffolder snippet is the near-term mitigation |
| 2. settingsNav.js card entry | B (now) | `get_settings_cards` |
| 3. asset-detail panels | B (now) | `get_asset_panels` (bespoke panels -> C, deferred) |
| 4. map markers / overlays | B (now) | `get_map_overlays` (bespoke overlays -> C, deferred) |
| 5. search-result rendering / routing | B (now) | `get_asset_presentation` |
| views (full list/detail/form) | C (deferred) | already glob-discovered under core `routes/*.js`; long-term move to `plugins/*/frontend/` per ADR-009 |

### Deferred: file-convention frontend tree (Option C)

Extending `import.meta.glob` from `./routes/*.js` to `plugins/*/frontend/` (so
views and the api-client module live with the plugin) is ADR-009 "Future
direction" steps 1-2. It is deferred for the same reasons ADR-009 gave: it is
build-system-heavy and the payoff only lands once out-of-tree plugins with their
own frontends are a real requirement. Until then, friction 1 stays mitigated by
the scaffolder snippet, and full views keep shipping in the core bundle and
gated by ADR-009 `meta.plugin`.

This is also the honest limit on the external-plugin wrinkle. The four data-only
hooks need **zero** frontend files from a plugin, so an external symlink-only
(backend) plugin gets settings cards, detail panels, map overlays, and search
presentation with no copy-into-`frontend/src/` step at all. The wrinkle survives
only for the residual component-backed cases, which is exactly the deferred
Option C work; pushing external-repo frontend distribution to that later ADR
matches ADR-003's posture of deferring out-of-tree packaging until two sites run
their own plugins.

### Contract-version impact (ADR-002)

Each of the four hooks is a new optional `BasePlugin` method - an additive
change, so a **minor** bump per ADR-002, the same classification `get_reports`
took at 0.6.0. Landing all four together is a single minor bump (proposed
**0.7.0**); landing them incrementally is one minor bump each. Adding the core
consumer endpoints and the generic renderer components is core-internal and does
not itself move `__contract_version__`. The deferred Option C introduces a
separate, versioned **frontend** contract (ADR-009 step 4), tracked apart from
the backend `__contract_version__`; it is not part of this proposal's bumps.

## Consequences

### Positive

- Closes the CONTRACT-STABILITY.md "single biggest gap" for the four surfaces a
  generic renderer can serve, using the already-proven declarative pattern - low
  risk, low cost, additive-only.
- External symlink-only plugins get four presentation surfaces with no
  copy-into-core step, shrinking (not yet eliminating) the ADR-003 wrinkle.
- Removes three of the two-plus hand edits the exemplar needed (settingsNav
  card, plus the AppLayout iconMap edit from section 9), moving them to data.
- Docs-drift guard forces documentation: `tests/test_docs_contract.py` fails if
  any new public `BasePlugin` hook is missing from `docs/PLUGIN-HOOKS.md`, so the
  hooks cannot ship undocumented.

### Negative

- Four more hooks and four more core consumer endpoints to maintain, each with
  the skip-disabled / fail-loud-in-dev / isolate-in-prod discipline.
- The generic renderers set a ceiling: bespoke panels and overlays still have no
  home until Option C lands, so the contract is honest-but-partial, not total.
- One extra request per surface on the pages that use it (a detail page fetches
  its panels; the map fetches overlays), on top of ADR-009's enabled-list fetch.

### Neutral

- Bundled plugins keep their current in-core views; migrating them to the hooks
  is opt-in and incremental (see Adoption plan), not a flag-day rewrite.
- `meta.plugin` route gating (ADR-009) is unchanged and still gates the views;
  these hooks add presentation, not routing.
- Contract tests (`tests/test_plugin_contract.py`) already assert every public
  hook is exercised; the new hooks slot into that harness.

## Adoption plan

Prove the contract on bundled plugins before declaring it settled for sister
sites.

1. **`get_asset_panels` first, via `warranty`.** Warranty is asset-general and
   already composes `WarrantyPanel.vue` onto multiple detail pages by hand, with
   a clean per-asset endpoint behind it. Migrating it to a declarative
   `get_asset_panels` entry rendered by the generic `AssetPanel` is the
   lowest-risk proof and immediately removes hand edits from every detail view
   that shows warranty. This is the recommended first migration.
2. **`get_settings_cards`, `get_map_overlays`, `get_asset_presentation` via
   `measuringtools`.** The guide exemplar already needs a settings card, a
   calibration-due map badge, and search routing for its `measuring_tool` type,
   so it exercises all three at once and its PLUGIN-GUIDE.md walkthrough becomes
   the reference for the new hooks.
3. Only after both plugins run on the hooks in a real build: bump
   `__contract_version__` to 0.7.0, document the hooks in
   `docs/PLUGIN-HOOKS.md`, and mark this ADR ACCEPTED.

## Open questions

- Should `get_asset_panels` `render` styles stay a small closed set
  (`keyvalue` / `table` / `badge`), or grow? A closed set keeps the renderer
  generic; growth pressure is the signal that a case actually needs Option C.
- Should the four consumer endpoints collapse into one bundled
  `GET /api/plugins/frontend-contributions` call to save round-trips, or stay
  separate per surface for cache locality? Defer until the per-surface request
  cost is measured.
- When Option C lands, does the api-client module move under
  `plugins/*/frontend/` or get replaced entirely by a generated client from the
  backend blueprint? Out of scope here; belongs to the ADR-009 frontend-contract
  follow-up.

## References

- ADR-001 (asset model as the map/search data source)
- ADR-002 (bump classification for the new hooks)
- ADR-003 (external-plugin distribution posture)
- ADR-009 (route gating; "Future direction" is the deferred Option C)
- docs/PLUGIN-GUIDE.md sections 9-10 (the measuringtools frontend hand edits)
- docs/CONTRACT-STABILITY.md (the expected-churn line this ADR answers)
- shopdb/plugins/base.py (existing declarative hooks this pattern extends)
- shopdb/core/api/dashboard.py, shopdb/core/api/reports.py (consumer precedent)
</content>
</invoke>
