Files
kandev/docs/decisions/2026-08-04-navigation-manifest-boundaries.md
T
2026-08-05 14:07:52 +01:00

77 lines
4.1 KiB
Markdown

# ADR-2026-08-04-navigation-manifest-boundaries: Centralize Navigation and Namespace Plugin Destinations
**Status:** accepted
**Date:** 2026-08-04
**Area:** frontend
## Context
The sidebar, mobile menu, and command palette previously declared overlapping
destination lists independently. That made navigation coverage difficult to
review and allowed `/stats` to remain unreachable on phones. The first shared
manifest fixed that gap, but its catalog, types, plugin mapping, surface policy,
and resolution rules initially lived in one growing module.
Plugin navigation introduced a second boundary problem. `NavItem.id` is local to
the plugin that registered it, while resolved destination IDs are React keys.
Using only the item ID allowed two plugins, or a plugin and a first-party
destination, to collide.
## Decision
- Keep one compact first-party catalog in `core-destinations.ts`. Destinations
explicitly declare the surfaces on which they may appear.
- Keep shared vocabulary in `types.ts`, plugin conversion and identity in
`plugin-destinations.ts`, filtering and copy/href resolution in
`resolve-destinations.ts`, and cross-surface policy in `surface-policy.ts`.
- Preserve plugin ownership through `PluginNavRegistration`. Build internal
plugin destination IDs as
`plugin:<encodeURIComponent(pluginId)>:<encodeURIComponent(itemId)>` and keep
the raw item ID separately only for compatibility selectors.
- Keep availability-gated and static consumers distinct. Static consumers must
not mount integration polling merely to render ungated links.
- Keep deep settings discovery in a dedicated settings catalog rather than
expanding `APP_DESTINATIONS`. The Settings tree and command palette resolve
that catalog, while stable target IDs connect entries to rendered controls.
Plugin-authored control discovery remains out of scope until the plugin
contract supplies explicit labels, aliases, routes, and target metadata.
The following concerns remain explicit follow-up decisions rather than fields or
validation added before a concrete requirement exists:
- Catalog order is shared across surfaces. Add per-surface order or priority
only when two surfaces need different ordering.
- Plugin nav paths remain links supplied by plugins. Static and nested routes
resolve before plugin routes, so plugins cannot serve or shadow first-party
pages, but link ownership is not yet validated.
- Route coverage continues to inspect the current route switch. Typed route IDs
shared by routing and navigation should replace that source scan before path
ownership validation is introduced.
- Jira, Linear, and Azure DevOps availability polling remains per mounted gated
consumer. Deduplication belongs in shared integration state, not navigation.
## Consequences
- Adding a first-party destination has one catalog entry and testable mobile
coverage rules, while mechanics can evolve without growing the catalog file.
- Plugin destination React keys are globally stable across owners without
changing existing plugin test IDs.
- New navigation consumers must choose the gated or static hook deliberately.
- Deep-settings consumers share one independently testable catalog without
turning top-level navigation entries into a settings schema.
- Future ordering, path ownership, and typed-route work has a discoverable
decision boundary instead of relying on comments in one implementation file.
## Alternatives Considered
- **Keep one navigation module until it reaches the line limit.** Rejected
because the catalog and its mechanics change for different reasons and were
already approaching the frontend file-size limit.
- **Use only `NavItem.id` for plugin destinations.** Rejected because plugin IDs
are not globally coordinated and resolved IDs are React keys.
- **Add priorities and path validation immediately.** Rejected because all
surfaces currently agree on order, while reliable path ownership depends on a
typed route catalog that does not yet exist.
- **Centralize integration polling in this change.** Rejected because polling is
owned by integration domain hooks and is independent of manifest structure.