---
name: admin-ui-view
description: Build or change an Admin panel screen — view module contract, routes and nav, the el/card/table/modal UI kit, access gating, XSS rules for player content, and the DOM traps that have shipped as bugs here. Use when editing app/Admin/assets/app.js, admin.css, or views/*.js.
---

# Building an Admin panel view

Frontend lives in `app/Admin/assets/`. No build step, no framework, no
dependencies — plain ES modules loaded straight by the browser.

## The contract

`assets/views/<name>.js` default-exports an async function:

```js
import { api, el, card, table, badge, toast, loading, errorBox } from "../app.js";

export default async function myView(root, params) {
    const d = await api("module.method", { params: { id: params.id } });
    root.append(card("Title", table(cols, d.rows, { empty: "Nothing here." })));
}
```

`root` is a fragment appended to the content area. `params` merges the query
string and the route's regex capture groups.

**Only import identifiers `app.js` actually exports.** There is no compile step;
a bad import fails at runtime on that route, and `node --check` will not catch
it.

## Wiring a new screen

In `app.js`:

```js
// routes
{ path: /^\/thing$/, view: "thing", crumbs: ["Thing"] },
{ path: /^\/thing\/(\d+)$/, view: "thing", crumbs: ["Thing", "Detail"], params: ["tid"] },

// navDef
{ label: "Thing", hash: "#/thing", icon: "activity", match: /^\/thing/, sec: "thing.view" },
```

`sec` on the **nav item** hides it from admins below the level. `sec` on the
**route** blocks direct navigation with a "No access" panel — add it when the
view has no backing endpoint to enforce access (a static page). Everything with
an endpoint is enforced server-side regardless.

## UI kit

| Helper | Use |
|---|---|
| `card(title, body, {sub, hint, actions})` | Standard panel. A body containing `.table-wrap` gets tight padding automatically |
| `table(cols, rows, {onRow, empty, sortable, foot})` | Data table |
| `modal({title, intro, fields, confirmLabel, danger, confirmClass})` | Async dialog; resolves `{values}` or `null` |
| `modalHead(title, onClose)` | Title bar **with the X**. Use it for any hand-built `.modal` — never a bare `.modal__head` |
| `reasonFields({label})` | The standard Reason input + "Show this reason to the player" pair, for player editors |
| `showReasonToggle()` | Inline `{node, get}` version of that tickbox, for editors that don't use `modal()` |
| `statCard`, `timeChart`, `sparkline` | Dashboard tiles and charts |
| `badge`, `serverBadge`, `activeBadge`, `empireName`, `nameId`, `ipCell` | Inline formatting |
| `toast(msg, "good"\|"bad")`, `loading()`, `errorBox(e)`, `confirmDanger(msg)` | Feedback |
| `fmtNum`, `fmtAbbr`, `levelName` | Formatting |
| `secLevel(sec)`, `canSec(sec)` | Access gating |

### Columns

```js
{ key: "name", label: "Empire" }                        // plain
{ label: "Turns", key: "turns", num: true }             // right-aligned, mono
{ label: "When", key: "datetime", dim: true }           // muted
{ label: "", nosort: true, render: r => badge(...) }    // custom cell
```

`num: true` right-aligns the **header and** the cells so figures line up under
their label. `sortable: true` enables click-to-sort; `sortValue` overrides the
sort key when `render` produces a node.

## XSS — the rule that matters

```js
el("div", {}, m.post)                  // TEXT node — always safe
el("div", { html: panelAuthored })     // innerHTML — panel-authored ONLY
```

**Any** player-controlled string — chat post, empire name, note, complaint —
goes in as a child. Never through `html`.

Game-authored HTML (the event feed) must pass through `sanitizeEvent()`, which
parses into an inert `<template>`, drops non-allowlisted tags, and strips `on*`
handlers, inline styles and `javascript:`/`data:` URLs. `battleReport()` is the
deliberate exception: it needs the `<table>`s the sanitizer removes, so it parses
raw HTML in an inert `DOMParser` document and reads **only** `textContent`.
Passing sanitized HTML to it once broke battle rendering entirely.

## Dialogs never close on a backdrop click

A click on the dark area beside a dialog does **nothing**. The exits are the X in
the title bar, Cancel, and Escape — all three, always.

This is not a style preference. These editors hold a lot of typed state (a
game-data row, a colony edit, a reason someone just wrote out) and a stray click
beside the dialog discarded the lot with no confirmation and no undo. It was a
daily occurrence when editing game data.

So when you hand-build a `.modal` rather than calling `modal()`:

```js
const bd = el("div", { class: "modal-backdrop", tabindex: "-1" },     // no onclick
    el("div", { class: "modal" },
        modalHead("Title", () => bd.remove()),                        // not a bare .modal__head
        el("div", { class: "modal__body" }, body),
        el("div", { class: "modal__foot" }, cancelBtn, saveBtn)));
document.body.append(bd);
bd.addEventListener("keydown", (e) => { if (e.key === "Escape") bd.remove(); });
bd.focus();
```

`modalHead()` exists so no dialog can ship without a visible way out. Adding
`onclick` to a `.modal-backdrop` re-introduces the bug.

## Player editors: the reason pair

Every dialog that edits a player and collects a reason uses `reasonFields()`, so
the "Show this reason to the player" option is worded and defaulted identically
everywhere:

```js
const res = await modal({ title, fields: [...myFields, ...reasonFields()] });
if (!res) return;
await api("users.thingSave", { method: "POST", body: {
    uid: u.id,
    reason: (res.values.reason || "").trim(),
    showreason: res.values.showreason,          // must be sent, or the box does nothing
    ...patch } });
```

Send `showreason` in the body — `Base.logChange()` reads it centrally, so no
endpoint change is needed, but a form that forgets the field silently offers an
option that has no effect. Editors that build their own form use
`showReasonToggle()` instead. Order matters where a "do not notify" box is also
present: show-reason goes **below** it, so the dialog reads "tell them at all?"
then "tell them why?".

The complaint resolve form in `views/moderation.js` (`actionRowsForm`) carries
the same toggle (offender on a sanction, complainer on No action), sent as `showreason` to `chatResolve` /
`pmResolve`. `showReasonToggle({ title })` rewords only the tooltip, for screens
where "the player" needs pinning down.

## DOM traps that have shipped as bugs

```js
node.replaceChildren(maybeNull);      // renders the literal string "null"
node.replaceChildren(arrayOfNodes);   // renders "[object HTMLDivElement]"
```

Both were live bugs. Correct forms:

```js
if (x) node.replaceChildren(x); else node.replaceChildren();
node.replaceChildren(...arrayOfNodes);
```

Also: `#` in a JS template literal is a plain character. `` `Complaint ##${id}` ``
renders `##47091`. `##` is CFML escaping and must never appear in `.js`.

## Gating the UI

Read the endpoint's `perms` struct and/or `canSec()`:

```js
const p = d.perms || {};
if (p.factionEdit) form.append(factionSelect);
if (canSec("players.action.silence")) actions.append(silenceBtn);
```

Gating is **cosmetic**. The server re-checks; never treat a hidden button as
protection. Prefer the endpoint omitting sensitive data entirely.

## State-driven controls

When an action changes a row's state, reflect it immediately *and* make the
server the source of truth on reload. The chat feed pattern:

- server sends per-row flags (`actioned`, `reported`, `removed`)
- the row renders controls from those flags
- on success the handler swaps the button for a marker in place

That way a reload and an in-session action agree. Don't rely on a page refresh
to show the result of an action.

## CSS

`assets/admin.css` is the panel's design system; the game's modern theme is
`app/theme.css` (theme-scoped selectors — follow the existing
`body:is([data-theme=…]), body[data-theme="classic"]` double-selector shape).

**Specificity ties bite.** A base rule like

```css
input[type="text"], input[type="password"], … { padding: 8px 11px; }
```

scores the same as `.gsearch input` and, coming later, overrides it. That put a
search icon on top of its placeholder. Fix by raising specificity deliberately
(`.gsearch input[type="text"]`), not with `!important`.

When a shared class renders on several surfaces (`.removed` appears in
`admin.css`, `theme.css`, and `i_chatt.cfm`), define it in **all** of them —
otherwise it silently looks fine on the one surface you tested.

## Verify

`node --check` the file, then load the route. For layout/cascade questions,
measure with `getComputedStyle` / `getBoundingClientRect` rather than eyeballing,
and cache-bust the stylesheet. Details in the `admin-verify` skill.
