---
key: spa.topbar-primary-action
title: '2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)'
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: The TopBar's primary-action "+" button renders only when the active route declares a non-empty `primaryAction`, is wired (via a shared `usePrimaryAction` hook) only on single-unambiguous-create-flow list screens, and is dropped everywhere else rather than left as a dead/no-op button.
signals: '`usePrimaryAction` hook, `ctv:primary-action` event, dead-button class · paths: `web/src/primaryAction.ts`, `docs/spa-conventions.md` §10 · issues: #238, #229, #247, #295'
mechanics: '`web/src/primaryAction.ts`, `docs/spa-conventions.md` §10'
---

The shell TopBar rendered a prominent top-right primary-action button (Plus icon) for **every** screen, but
only `SchedulesScreen` had ever subscribed to its `ctv:primary-action` event — so on every other screen the
button was **dead** (either a labelled no-op like "Save Changes"/"Add Channel", or, for the ~10 routes whose
`primaryAction` was `''`, a bare labelless "+"; the button rendered unconditionally). Issue #238 (from a #229
live-E2E finding, pre-existing since the TopBar's introduction).

**Decision — the Plus-icon button is a "create new item" affordance; keep+wire it only where that fits:**

- **TopBar renders the button only when the active route declares a non-empty `primaryAction`** (was:
  unconditional). This alone removes every empty-`''` dead "+".
- **WIRE** (8 list screens with a single unambiguous create flow) via a shared `usePrimaryAction(routeId,
  handler)` hook (`web/src/primaryAction.ts`), each delegating to the same top-level create handler its in-body
  control uses (`navigateToPath('/app/new-channel')`, `setEditing({kind:'new'})`, `navigateToPath('…/add')`,
  etc.): channels, schedules (refactored onto the hook), multiCollections, rerunCollections, traktLists,
  fillerPresets, ffmpegProfiles, watermarks.
- **DROP** (`primaryAction: ''`, no button) everywhere else, for one of four reasons: (1) the action isn't a
  create so the "+" is wrong and a correct in-body control already exists — editChannel (savebar), settings
  (savebar), guide (Jump to now), playouts (Reset all), logs/troubleshooting/blockPlayoutTroubleshooting
  (Refresh), playbackTroubleshooting (Play), yamlValidator (Validate); (2) ambiguous — collections (two create
  types behind tabs); (3) a silent no-op — builder ("Create Channel" is disabled until the form is valid),
  playlists (needs a group first); (4) semantically misplaced — dashboard (status page), libraries ("Scan" is
  per-library-row, no global target).

**Why not wire everything** (the SchedulesScreen precedent): the recon found every screen already carries a
correct, disabled-state-aware, context-aware in-body control, and several banner actions are unreachable
without refactoring handlers out from under early returns, or would render a silent no-op — reintroducing the
very dead-button class #238 fixes. Wiring only the single-create-flow screens gives one explainable rule
("primary button = create a new item on a list screen") and needs no risky refactors.

**Also fixed here:** the `apiKey` route's `primaryAction: 'Save key'` + its description/comment were stale
post-#295 (the screen displays the machine key + changes the local password; there is nothing to "save") —
relabelled to reflect the #295 reality. Convention + hook documented in `spa-conventions.md` §10. The
implicit route-label ⟺ screen-subscription coupling is the kind of thing #247 (shell extraction) will
formalize; #238 keeps it a documented convention guarded by a data-driven `App.test.tsx` test over the URL-navigating
create screens (a typo'd route id → the banner navigates nowhere → red). Refs #238 #247.
