dashboard: new S3TT1NGS tab housing the notification toggle (closes #763)

Adds a fifth tab (◆ S3TT1NGS ◆) at the end of the dashboard tab
strip; moves the browser-notification controls out of the chrome
header into the new pane.

### Why

The chrome was carrying the notification controls as 'always-on
ergonomics' but the surface they belong on is operator preferences,
not navigation. With #589 / #701 likely adding more per-browser
preferences later (theme / density / etc.), having a dedicated
S3TT1NGS pane gives them a home so the chrome strip doesn't keep
accreting controls.

### Shape

- index.html: new `#tab-pane-settings` section with H2 + intro +
  H3 'browser notifications' group; same notif-row DOM (same IDs)
  migrated from the chrome
- flow.html: drops the duplicated notif-row from its chrome (FL0W
  reused the dashboard's strip); adds a `◆ S3TT1NGS ◆ →`
  cross-page link to `/#settings`. Notifications still fire on
  FL0W when enabled — NOTIF.show() reads
  Notification.permission + the hyperhive.notify.muted localStorage
  key directly, no buttons needed in the page DOM.
- tabs.js: adds 'settings' to TABS array so hash routing activates
  the pane
- dashboard.css: drops the chrome-anchoring `.tabbar #notif-row`
  rule and the flow-page reuse comment, both obsolete; the general
  `.notif-row` styling stays
- docs/web-ui.md: new `### S3TT1NGS tab` section; Chrome-header
  bullet drops 'Notification controls' + adds S3TT1NGS; Browser
  notifications section pointers at the new pane

### No JS or backend changes

Same IDs (notif-row, notif-enable, notif-mute, notif-unmute,
notif-status) so `NOTIF.bind()` in common.js wires the new pane
unchanged. NOTIF.show() dispatch is DOM-independent.

### Validation

`npm run --workspace @hive/dashboard build` clean (tabs.js 167.8kb,
flow.js 37.4kb, dashboard.css 50.2kb).
This commit is contained in:
iris 2026-05-31 17:53:03 +02:00 committed by mara
commit 6cf169d324
5 changed files with 92 additions and 52 deletions

View file

@ -284,20 +284,22 @@ cross-page link (`◆ FL0W ◆ →`), not a pane swap.
**Chrome header** (fixed, overlays the active tab pane):
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
`◆ SCH3DUL3S ◆`, `◆ M4TR1X ◆ →` (optional page link, see below),
and `◆ FL0W ◆ →` (page link). Count pills on SW4RM (container
count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S
(active schedules); FL0W pill mirrors the operator inbox length
(hidden when zero). The M4TR1X → entry is hidden when
`◆ FL0W ◆ →` (page link), and `◆ S3TT1NGS ◆`. Count pills on
SW4RM (container count), Y3R C4LL (pending approvals + questions),
and SCH3DUL3S (active schedules); FL0W pill mirrors the operator
inbox length (hidden when zero); S3TT1NGS has no count. The
M4TR1X → entry is hidden when
`services.hyperhive.matrix.gui.enable` is off (defaults to
`matrix.enable`) so operators without the matrix GUI on don't
see a dead link — tabs.js gates the `hidden` attribute on
`state.matrix_gui_enabled` from `/api/state`.
- **Notification controls**: `🔔 enable notifications` when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted.
Always visible in the chrome regardless of active tab.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
The FL0W page reuses the same chrome strip but its `◆ S3TT1NGS ◆ →`
entry is a cross-page link back to the dashboard
(`/#settings`) since the settings pane only lives there.
### SW4RM tab
**C0NTAINERS** — live containers rendered as a depth-first
@ -445,6 +447,32 @@ button hard-deletes (`POST /cancel-reminder/{id}`) and a
Lives in the SCH3DUL3S tab alongside operator schedules so the
operator has one place for everything time-fired.
### S3TT1NGS tab
Operator-local preferences. State lives in the browser's
`localStorage` — preferences do NOT sync between devices and
do NOT survive a profile wipe. Today the tab holds one section
(browser notifications); future preferences (theme, density,
inbox-pill threshold, etc.) land here as sibling `<h3>` blocks
under the same `<section id="tab-pane-settings">`.
**◇ browser notifications** — `🔔 enable notifications` button when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted
(mute silences the dispatch without revoking the OS-level
permission). On unsupported origins (non-secure context, or
browsers without the `Notification` API) the controls hide and a
single status line explains why. See `### Browser notifications`
below for the dispatch model + the three signals the dashboard
emits OS notifications on.
The FL0W page does NOT host this pane — its tab-strip
`◆ S3TT1NGS ◆ →` entry is a cross-page link to the dashboard's
`#settings` route. Notifications still fire on the FL0W page when
they're enabled here, because `NOTIF.show()` in
`common.js` depends on `Notification.permission` + the
`hyperhive.notify.muted` localStorage key, not on the buttons
existing in the page DOM.
### M4TR1X page (`/matrix/`, optional)
A static matrix web client (default `pkgs.fluffychat-web` rebuilt
@ -755,6 +783,10 @@ Pure frontend (`Notification` API). Three signals trigger them:
- new pending operator question (per id)
- new broker message sent `to: "operator"` (live via SSE)
The toggle controls live in the `S3TT1NGS` tab (`#settings`); see
that section above for the user-facing shape. Dispatch logic lives
in `common.js::NOTIF`.
First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count.
Per-event tags (`hyperhive:approval:<id>`, `hyperhive:question:<id>`,
@ -762,12 +794,13 @@ Per-event tags (`hyperhive:approval:<id>`, `hyperhive:question:<id>`,
notification center instead of overwriting each other.
`console.debug` logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle
silences without revoking the OS permission. Requires a secure
context (HTTPS or localhost); on other origins the controls hide
themselves. Browsers typically suppress notifications while the
originating tab is focused — that's a browser-level decision,
not ours.
focuses the dashboard tab. The localStorage key
`hyperhive.notify.muted` (`"1"` = muted, absent = unmuted) backs
the toggle and silences dispatch without revoking the OS
permission. Requires a secure context (HTTPS or localhost); on
other origins the controls hide themselves. Browsers typically
suppress notifications while the originating tab is focused —
that's a browser-level decision, not ours.
### Dashboard endpoints

View file

@ -98,15 +98,6 @@ body.dashboard-shell {
color: var(--red);
}
/* Notification controls cohabit with the tabs (always-on chrome). */
.tabbar #notif-row {
margin-left: auto;
display: flex;
gap: 0.5em;
align-items: center;
padding-right: 0.5em;
}
/* Tab pane visibility show only the active one. The .tab-pane-active
class is set by tabs.js based on the URL hash; default (no hash)
resolves to SW4RM. */
@ -1582,10 +1573,6 @@ body.flow-shell .tabbar .tab.active.tab-link {
border-color: var(--purple-dim);
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4);
}
/* `.notif-row` styling lives under the shared `.tabbar #notif-row`
selector earlier in the file the flow page reuses the dashboard
tab strip rather than carrying its own title/hint/back chrome. */
/* Inbox pill operator inbox flyout trigger. Sits right under the
header so it stays in the operator's gaze without crowding the
chat. Same shape as the agent page's header pills. */

View file

@ -38,17 +38,15 @@
<span class="tab-label">◆ FL0W ◆</span>
<span class="tab-count" id="tab-count-flow" hidden></span>
</a>
<!-- Notif controls cohabit with the tabs (always-on chrome).
Same IDs as on the dashboard so the shared NOTIF binding
(from common.js, imported by both tabs.js and flow.js)
picks them up unchanged. -->
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
<!-- S3TT1NGS cross-links to the dashboard tab — notification
toggle + future preferences live there now. `NOTIF.show()`
in common.js doesn't depend on the buttons existing here,
so notifications still fire on this page when they're
enabled (or muted) in dashboard settings. -->
<a class="tab tab-link" href="/#settings"
title="open S3TT1NGS on the dashboard">
<span class="tab-label">◆ S3TT1NGS ◆ →</span>
</a>
</nav>
</header>

View file

@ -69,14 +69,15 @@
<span class="tab-count" id="tab-count-flow" hidden></span>
</a>
<!-- Notification controls live in the chrome (always-on
ergonomics; not tab-specific). -->
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
<!-- S3TT1NGS: operator-local preferences (browser notification
toggle today; future: theme, density). Sits at the end of
the strip because it's rarely-touched and not a primary
surface. See docs/web-ui.md::S3TT1NGS tab. -->
<a class="tab" id="tab-settings" href="#settings" role="tab"
aria-controls="tab-pane-settings"
data-tab="settings">
<span class="tab-label">◆ S3TT1NGS ◆</span>
</a>
</nav>
</header>
@ -178,6 +179,27 @@
tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). -->
<!-- S3TT1NGS: operator-local preferences. Today this is just the
browser-notification toggle (per-browser localStorage state).
The same IDs that used to live in the tab strip migrated
here unchanged so `NOTIF.bind()` in common.js wires them
without any JS change. -->
<section class="tab-pane" id="tab-pane-settings"
role="tabpanel" aria-labelledby="tab-settings">
<h2>◆ S3TT1NGS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">operator-local preferences. these live in the browser's localStorage — they do not sync between devices and do not survive a profile wipe.</p>
<h3>◇ browser notifications</h3>
<p class="meta">desktop notifications for new approvals, new operator questions, and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</section>
</main>
<footer>

View file

@ -3219,13 +3219,13 @@ window.marked = marked;
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` activate the matching
// pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT
// a tab — it's a separate page (`/flow.html`) reached via the
// tab-strip link. Tab routing only applies when the tab DOM is
// present (e.g. not on the flow page itself, where these elements
// don't exist and the loop no-ops).
const TABS = ['swarm', 'call', 'system', 'schedules'];
// Hash-based: `#swarm` / `#call` / `#system` / `#schedules` /
// `#settings` activate the matching pane on the dashboard. Empty
// hash defaults to SW4RM. FL0W is NOT a tab — it's a separate page
// (`/flow.html`) reached via the tab-strip link. Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops).
const TABS = ['swarm', 'call', 'system', 'schedules', 'settings'];
function activateTab(name) {
const target = TABS.includes(name) ? name : TABS[0];
for (const t of TABS) {