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): **Chrome header** (fixed, overlays the active tab pane):
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`, - **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
`◆ SCH3DUL3S ◆`, `◆ M4TR1X ◆ →` (optional page link, see below), `◆ SCH3DUL3S ◆`, `◆ M4TR1X ◆ →` (optional page link, see below),
and `◆ FL0W ◆ →` (page link). Count pills on SW4RM (container `◆ FL0W ◆ →` (page link), and `◆ S3TT1NGS ◆`. Count pills on
count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S SW4RM (container count), Y3R C4LL (pending approvals + questions),
(active schedules); FL0W pill mirrors the operator inbox length and SCH3DUL3S (active schedules); FL0W pill mirrors the operator
(hidden when zero). The M4TR1X → entry is hidden when inbox length (hidden when zero); S3TT1NGS has no count. The
M4TR1X → entry is hidden when
`services.hyperhive.matrix.gui.enable` is off (defaults to `services.hyperhive.matrix.gui.enable` is off (defaults to
`matrix.enable`) so operators without the matrix GUI on don't `matrix.enable`) so operators without the matrix GUI on don't
see a dead link — tabs.js gates the `hidden` attribute on see a dead link — tabs.js gates the `hidden` attribute on
`state.matrix_gui_enabled` from `/api/state`. `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 ░▒▓█▓▒░`) - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip. — 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 ### SW4RM tab
**C0NTAINERS** — live containers rendered as a depth-first **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 Lives in the SCH3DUL3S tab alongside operator schedules so the
operator has one place for everything time-fired. 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) ### M4TR1X page (`/matrix/`, optional)
A static matrix web client (default `pkgs.fluffychat-web` rebuilt 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 pending operator question (per id)
- new broker message sent `to: "operator"` (live via SSE) - 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 First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count. firing — only items that arrive while the page is open count.
Per-event tags (`hyperhive:approval:<id>`, `hyperhive:question:<id>`, 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. notification center instead of overwriting each other.
`console.debug` logs at every block point (unsupported, `console.debug` logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle focuses the dashboard tab. The localStorage key
silences without revoking the OS permission. Requires a secure `hyperhive.notify.muted` (`"1"` = muted, absent = unmuted) backs
context (HTTPS or localhost); on other origins the controls hide the toggle and silences dispatch without revoking the OS
themselves. Browsers typically suppress notifications while the permission. Requires a secure context (HTTPS or localhost); on
originating tab is focused — that's a browser-level decision, other origins the controls hide themselves. Browsers typically
not ours. suppress notifications while the originating tab is focused —
that's a browser-level decision, not ours.
### Dashboard endpoints ### Dashboard endpoints

View file

@ -98,15 +98,6 @@ body.dashboard-shell {
color: var(--red); 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 /* 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) class is set by tabs.js based on the URL hash; default (no hash)
resolves to SW4RM. */ resolves to SW4RM. */
@ -1582,10 +1573,6 @@ body.flow-shell .tabbar .tab.active.tab-link {
border-color: var(--purple-dim); border-color: var(--purple-dim);
box-shadow: 0 -2px 12px -4px rgba(203, 166, 247, 0.4); 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 /* Inbox pill operator inbox flyout trigger. Sits right under the
header so it stays in the operator's gaze without crowding the header so it stays in the operator's gaze without crowding the
chat. Same shape as the agent page's header pills. */ 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-label">◆ FL0W ◆</span>
<span class="tab-count" id="tab-count-flow" hidden></span> <span class="tab-count" id="tab-count-flow" hidden></span>
</a> </a>
<!-- S3TT1NGS cross-links to the dashboard tab — notification
<!-- Notif controls cohabit with the tabs (always-on chrome). toggle + future preferences live there now. `NOTIF.show()`
Same IDs as on the dashboard so the shared NOTIF binding in common.js doesn't depend on the buttons existing here,
(from common.js, imported by both tabs.js and flow.js) so notifications still fire on this page when they're
picks them up unchanged. --> enabled (or muted) in dashboard settings. -->
<div id="notif-row" class="notif-row"> <a class="tab tab-link" href="/#settings"
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button> title="open S3TT1NGS on the dashboard">
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button> <span class="tab-label">◆ S3TT1NGS ◆ →</span>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button> </a>
<span id="notif-status" class="meta" hidden></span>
</div>
</nav> </nav>
</header> </header>

View file

@ -69,14 +69,15 @@
<span class="tab-count" id="tab-count-flow" hidden></span> <span class="tab-count" id="tab-count-flow" hidden></span>
</a> </a>
<!-- Notification controls live in the chrome (always-on <!-- S3TT1NGS: operator-local preferences (browser notification
ergonomics; not tab-specific). --> toggle today; future: theme, density). Sits at the end of
<div id="notif-row" class="notif-row"> the strip because it's rarely-touched and not a primary
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button> surface. See docs/web-ui.md::S3TT1NGS tab. -->
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button> <a class="tab" id="tab-settings" href="#settings" role="tab"
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button> aria-controls="tab-pane-settings"
<span id="notif-status" class="meta" hidden></span> data-tab="settings">
</div> <span class="tab-label">◆ S3TT1NGS ◆</span>
</a>
</nav> </nav>
</header> </header>
@ -178,6 +179,27 @@
tabs.js boots on this page the corresponding renderers tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). --> 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> </main>
<footer> <footer>

View file

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