# Design note — the menu manager

**Dated 2026-08-04. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

The first of the three high-priority requests queued in the roadmap. Reasoned
from the shape of the templates that exist today and from what the job actually
is; where something is simply standard across forum software, it says so.

---

## The job to be done

An administrator wants to change what is in the navigation without editing a
template. Add a link to a rules page, drop one they do not use, put three
related things under one heading, and show a link only to the people it applies
to.

## What is there now

`themes/default/templates/layouts/main.cvr` hardcodes two links — Forums and
Activity — beside a set of controls: the theme toggle, the search trigger, the
notifications bell, the messages link, the admin link, the username and log
out.

**That distinction is the design.** Those controls are not navigation; they are
session widgets, each with behaviour attached. A menu manager that swallowed
them would let an administrator delete their own way to sign out. So the menu
owns the *links*, and the controls stay where they are.

## Decision 1 — zones are rows, not an enum

A menu belongs to a zone identified by a string key: `primary`, `footer`, and
whatever a theme or extension wants to render.

Two zones ship and are rendered: **`primary`** (header) and **`footer`**. The
roadmap also asks for a user-dropdown zone and a mobile zone. Those are one
seeded row each — but neither has anywhere to render today, because the header
has no dropdown and the theme has no separate mobile nav. **Creating a zone
that renders nowhere would be a trap**, so they wait for the UI to exist, which
is a visual-design decision rather than a menu one.

## Decision 2 — an item is a label and a target, and the target is validated

| Column | Why |
|---|---|
| `menu_id`, `parent_id`, `position` | Where it sits |
| `label` | What it says |
| `type` | `route` (internal path) or `url` (absolute) |
| `target` | The path or URL |
| `icon` | Optional; the site already has a Font Awesome kit setting |
| `new_tab` | Only meaningful for `url` |
| `is_enabled` | Turn a link off without losing how it was set up |

The two types exist because they need different handling. An internal path
should stay relative so the site works behind any host; an external URL needs
`rel="noopener"` and is where `target="_blank"` makes sense.

**A target is validated on the way in, not on the way out.** For `url`, only
`http` and `https` — a `javascript:` URL in a nav link is stored XSS with a
friendly name, and it is an administrator field, which makes it *more* worth
checking rather than less, because an admin account is what an attacker wants.
For `route`, it must begin with a single `/`, which stops `//evil.example.com`
being read as a protocol-relative URL by the browser.

## Decision 3 — visibility is group membership, and guests are a group

Convoro already has a Guests group with `is_guest = 1`. So visibility does not
need its own vocabulary: an item is visible to a set of groups, and a
signed-out visitor is treated as being in the guest group.

- No groups selected → visible to everyone. That is the common case and it
  should be the one that needs no thought.
- Guests only → a Register link.
- Members but not Guests → a link only signed-in people see.

One concept instead of two (a `visibility` enum *plus* a group list), and it
composes: "moderators and administrators" is just two rows.

**A hidden link is not a permission.** The page behind it must check for
itself. This is worth writing down because a nav that hides a link *looks* like
access control and is not.

## Decision 4 — menus reach the page through the hook system

The menu module renders into `header.nav` and `footer.start`, using the hooks
built in phase 5 rather than new template code.

This is deliberate dogfooding: if the hook system cannot carry a first-party
feature, it will not carry a third-party one either. It also means the menu is
rendered the same way an extension would render it, and the theme keeps one
insertion point instead of a special case.

That needs one small addition. A hook callback receives its own context but has
no access to the template's shared data, so it cannot see who is looking. The
engine gains `shared()`, and `StartSession` shares the viewer and their group
ids — which also fixes something older: `user` was passed by every controller
by hand, and the layout read it as `$user ?? null`.

## Decision 5 — the tree editor works without dragging

Drag-to-reorder is what the roadmap asks for and it is the right primary
gesture. It is not the only one:

- **Buttons** for up, down, indent and outdent, which is what the forum and
  prefix admin screens already use, so the idiom is established.
- **Keyboard** on the same controls, because a drag-only tree cannot be
  operated without a mouse.
- **Drag** as the fast path on top.

Written in plain JavaScript against the existing markup. TipTap is the only
third-party front-end dependency and this is not a good reason to add a second.

The order is saved explicitly rather than on every drop. A tree that saves
itself as you rearrange it has no cancel, and rearranging a menu is exactly the
kind of thing somebody does and then thinks better of.

## Decision 6 — depth stops at two

Top level and one level of children. Not because more is hard, but because a
third level of hover-nested navigation is unusable on a phone and nobody asked
for it. The schema is a self-referencing `parent_id` and would carry more; the
editor and the renderer refuse it.

## What this does not do

- **No mega-menus, no columns, no images in the dropdown.** Layout is theming.
- **No per-item caching.** Two queries per page render, and the tree is small.
  Worth revisiting only if it ever shows up in a profile.
- **No automatic link to every forum.** A dynamic "all forums" item sounds
  helpful and produces a menu that grows without bound; the forum list page
  already does that job.
