# op-panel

A collapsible **operational status panel** for gf.cx surfaces. At rest it shows
a roll-up of `card--stat` tiles and a `+` marker; expand it and the full detail —
a sortable job table — is revealed. One shared **sort segment** (failing-first /
A–Z / recent) and a **list ↔ cards** view toggle drive every panel on the page at
once, from a handful of data-attributes. Powers every panel on
[status.gf.cx](https://status.gf.cx).

Demo + docs: <https://assets.gf.cx/op-panel/>

## Import

```html
<link rel="stylesheet" href="https://assets.gf.cx/op-panel/op-panel.css">
<script src="https://assets.gf.cx/op-panel/op-panel.js" defer></script>
```

`op-panel.css` consumes the gf.cx design-system tokens (`--accent`, `--line`,
`--ink-soft`, `--red`, `--gold`, `--mono`, …) with hex fallbacks, so it themes
correctly on any surface and adopts your palette when you define those tokens.

## Markup contract

```html
<section class="op-panel op-panel--collapsible" data-panel
         data-view="list" data-default-sort="status">
  <details class="op-collapse" open>
    <summary class="op-collapse__summary">
      <div class="op-collapse__bar"><h2>Scheduled jobs</h2>
        <span class="op-collapse__marker" aria-hidden="true"></span></div>
      <div class="op-rollup"><!-- card--stat tiles --></div>
    </summary>
    <div class="op-collapse__body">
      <div class="op-panel__head op-panel__head--controls">
        <div class="op-controls">
          <span class="op-sort" role="group" aria-label="sort order">
            <button class="op-sort__btn" data-sort="status">failing first</button>
            <button class="op-sort__btn" data-sort="name">A&ndash;Z</button>
            <button class="op-sort__btn" data-sort="recent">recent</button>
          </span>
          <span class="view-toggle" role="group" aria-label="view">
            <button class="view-toggle__btn" data-view="list">list</button>
            <button class="view-toggle__btn" data-view="cards">cards</button>
          </span>
        </div>
      </div>
      <table class="scheduled-jobs">
        <thead><tr><th>Job</th><th>Status</th><th>Ran</th></tr></thead>
        <tbody data-sortlist>
          <tr class="sched-row sched-row--failed"
              data-rank="0" data-label="bunny-noir-mirror" data-age="98">
            <td class="sched-label" data-th="Job">bunny-noir-mirror</td>
            <td class="sched-status" data-th="Status">✗ failed</td>
            <td data-th="Ran">2h ago</td>
          </tr>
          <!-- … -->
        </tbody>
      </table>
    </div>
  </details>
</section>
```

### Row data-attributes (the sort contract)

| attribute | drives | notes |
|-----------|--------|-------|
| `data-rank` | **status** sort (ascending) | lower = more urgent; 0 = failing |
| `data-label` | **A–Z** sort + status tiebreak | the row's name |
| `data-age`  | **recent** sort (descending) | recency score, **bigger = newer**; omit → sinks to bottom |
| `data-th` (per `<td>`) | card-view label | shown as the key in cards view |

Verdict classes on the `<tr>` paint the left rail + tint:
`sched-row--failed` (red), `--recovered` / `--not-loaded` (amber),
`--pending` (neutral).

### Generic card view for any table (`.op-cardable`)

The status-hub table above uses `.scheduled-jobs` / `.sched-row` (which also carry
verdict rails + a failing-first health sort). To give **any** plain table the same
list↔cards toggle on any surface — without the sched-* semantics — opt in with
three marks and keep your existing sort. (op-panel's own sort only reorders rows
carrying `data-rank`, so a table without it is left untouched — pair `.op-cardable`
with the [`sortable-table`](https://assets.gf.cx/sortable-table/) column-click
primitive and the two don't collide.)

```html
<section class="op-panel" data-panel data-view="list">
  <!-- your list/cards view-toggle -->
  <table class="op-cardable" data-sortable>
    <thead>…</thead>
    <tbody data-sortlist>
      <tr>
        <td data-card-title data-th="Mode">single-stream</td>
        <td data-th="Best">8.6</td>
        <!-- … each <td> gets data-th="<its column>" -->
      </tr>
    </tbody>
  </table>
</section>
```

`data-card-title` marks the cell that becomes the card head; every other `data-th`
cell renders as a `KEY  value` line in cards view. No `data-rank`/`data-age`
needed — sort stays yours.

## Behaviour (op-panel.js)

- One handler binds **every** `[data-panel]` on the page. Sort + view are
  page-level (one choice applies to all panels) and persist to `localStorage`
  (`gfcx_op_sort`, `gfcx_op_view`).
- Rows reorder in place inside any `[data-sortlist]` container by `data-rank` /
  `data-label` / `data-age`. The card view is **pure CSS** (`data-view` on the
  panel) — the same `<tr>` rows reflow to a grid, no second DOM copy.
- **Roll-up card = sort control.** A `card--stat` tile in the summary becomes a
  click-to-sort control for that panel's table. A card with `data-conn` floats
  the matching `data-conn` rows to the top (sort-by-entity); a health / status /
  time card (labels `Healthy`/`Passing`, `Failing`/`Tripped`, `Last run`/`Polled`
  /`Verified` …) picks the matching comparator. The cards sit inside the summary,
  so the rule is state-aware: **collapsed → open + sort; open → sort in place
  (`preventDefault`, so the panel never collapses); click the active card again →
  clear to default.** Count/total cards are left inert. The panel still closes via
  its title / `+−` marker. `is-pinned` marks the active card (style it yourself).
- **Deep-link → open-on-arrival.** A `.stack-strip a[href="#id"]` (or any hash in
  the URL / a `hashchange`) opens the target section's `<details>` **before** the
  browser's jump, so you land on expanded content, not a folded summary. A panel
  that must reset to its default sort on arrival carries `data-reset-sort-on-open`.
- Anti-drift: a new panel needs only the markup above — no JS edit.

Load with `defer`.

### Why this logic lives in the module, not inline

The roll-up-card sort + deep-link handlers used to be an inline `<script>` in the
host page. Cloudflare **Rocket Loader** silently truncated that block at an
HTML-looking token inside a JS comment (`<script>` / `</body>`), so every
card-click fell through to the native `<details>` toggle and **collapsed** the
panel instead of sorting. An external module script is immune to that class of
parse bug. **Rule: never put a `script` / `body` / closing-tag literal (or any
HTML tag) inside inline JS on a Rocket-Loader-enabled page — keep interactive
logic in this module.**

## What's NOT in the module

Surface-specific extensions layer on top in the host page and are intentionally
excluded: transit sparklines (`.spk-*`), per-surface green-flash pulses
(`.is-flashing`), the `#active` two-pane swap, and semantic per-panel *filters*
(e.g. the agent-readiness cards that hide/reveal rows rather than sort). The
generic **sort-by-entity roll-up pin** (`.card--pin`) is now IN the module — only
its visual styling (`.card--pin`, `.is-pinned`) stays in your surface's CSS.
Keep the rest in your surface's own CSS/JS.

## Single source of truth

This directory is canonical. The status-hub renderer
(`xlab-co-toolkit/gfcx_status_hub_render.py`) `<link>`/`<script src>`-imports
these files rather than inlining a copy — the favicon-control-plane / `made-with`
model applied to an interactive component.
