Skip to content

feat(announcements): render the modal surface with the §D8 gate (PR-5) - #977

Merged
philmerrell merged 1 commit into
developfrom
feature/announcement-modal
Sep 6, 2026
Merged

feat(announcements): render the modal surface with the §D8 gate (PR-5)#977
philmerrell merged 1 commit into
developfrom
feature/announcement-modal

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Fifth and last surface PR in the feature-announcements epic (spec: docs/specs/feature-announcements.md). The spec calls this the highest-risk PR — "everything that can annoy a user lives here." Only PR-6 (/stats) remains after it.

Independent of #976 (PR-4). Branched from develop and touches no file PR-4 touches — the modal is started from app.config.ts, not mounted in app.html. Either can merge first. That is deliberate: per the #419 post-mortem, stacking a PR whose base merges first is how a commit gets stranded.

The gate is the substance

AnnouncementModalService opens the modal on route settle, and only when:

  • no active stream (MessageMapService.isLoadingSession() is null)
  • no pending tool-approval, OAuth-consent, or MCP-App-consent prompt
  • no draft in a focused composer
  • the route is not minimal-chrome (don't put a dialog over a shared artifact someone opened from a link)

The consent checks are not redundant with the stream check. Per docs/specs/mid-turn-steering.md (#934), isLoading() is false while a turn is paused on an interrupt — so a stream-only gate would happily throw a dialog over an OAuth consent prompt and steal its focus. The prompt services are asked directly. The spec names tool-approval and oauth-consent; MCP-App consent is the same class of prompt and is included for the same reason.

Every gate input is read untracked, and that is load-bearing. Read reactively, the effect would re-run the instant a stream ended or a consent was answered — firing a modal into the middle of the session, seconds after the user finished a thought. §D8 forbids exactly that: a failed gate leaves the announcement eligible, it does not queue it. The effect therefore tracks only the announcement and a navigation counter and snapshots the rest. There is a spec for this specifically (does NOT fire when the blocker merely clears mid-session), because it is the kind of thing a later refactor would "clean up" by deleting the untracked call.

requiresAck

The confirm button is the only exit: no ✕, disableClose on the overlay, and the in-component Escape and backdrop handlers return without writing an ack. Belt and braces on purpose — the CDK option and the guards fail independently, and being stuck under an undismissable dialog is the worst outcome this feature can produce.

The button label follows the ack it writes — "I understand" → acknowledged, "Got it" → dismissed — so it cannot misdescribe the record it creates.

Smaller things

  • Started via provideAppInitializer, same shape as ThemeService. A CDK overlay is not a layout element, and nothing else would ever inject the service.
  • Body renders through .message-block, not prose — the typography plugin is not installed, so the older user-menu-link-modal's classes are inert and preflight strips list markers.
  • Sanitization stays on (§D10). admin.announcements is delegable, so this body may be authored by someone who is not a platform admin and it is broadcast to every user.

Verification

Unit: 23 specs across the component and the gate, including the #934 shape (consent pending while the stream reads idle) and the mid-session-clear case above.

End to end against dev data with a local app-api:

Result
requiresAck on load opens; no ✕; Escape and backdrop clicks leave it open
confirm button writes acknowledged at rank 3, upgrading the seen row in place rather than duplicating
requiresAck off ✕ and Escape both write dismissed at rank 2
after either server returns modal: null, both panel entries survive (§D1/§D2)
appearance light, dark, and 375px bottom-sheet with no horizontal overflow

Full SPA suite: no new failures. The 4 in admin/marketplace/pages/submission-review.page.spec.ts are pre-existing — clean develop fails the same file with 5, the known isolate: false cross-file flake.

Test data created in dev was deleted afterwards; the table is back to just the "Welcome to Dev" announcement.

Still open in the epic

  • PR-6/stats + ack counters + admin reporting.
  • D10 audit logging is still deferred. The spec requires announcement writes to go through the admin.audit trail; AuditAction is a closed namespace of role-only actions and extending it touches the non-delegable admin.audit scope. Worth a decision before requiresAck is used for anything policy-shaped — an acknowledgement record with no audit trail behind the authoring side is a weaker record than it looks.

🤖 Generated with Claude Code

The interruptive surface, and the last one. `components/announcement-modal`
renders the single modal the server picked; `AnnouncementModalService` decides
whether interrupting is acceptable at all.

The gate is the substance of this PR. The modal opens on route settle and only
when there is no active stream, no pending tool-approval / OAuth-consent /
MCP-App-consent prompt, no draft in a focused composer, and the route is not a
minimal-chrome page. The consent checks are not redundant with the stream
check: per mid-turn-steering (#934) `isLoading()` is FALSE while a turn is
paused on an interrupt, so a stream-only gate would throw a dialog over an
OAuth consent prompt and steal its focus. The prompt services are asked
directly.

Every gate input is read `untracked`. Read reactively, the effect would re-run
the instant a stream ended or a consent was answered and fire a modal seconds
after the user finished a thought — which §D8 forbids in as many words: a
failed gate leaves the announcement eligible for the next clean load, it does
not queue it. So the effect tracks only the announcement and a navigation
counter, and snapshots the rest.

`requiresAck` makes the confirm button the only exit: no ✕, `disableClose` on
the overlay, and the in-component Escape and backdrop handlers return without
writing an ack. Belt and braces on purpose — the CDK option and the guards
fail independently. The button label follows the ack it writes, "I understand"
→ `acknowledged` and "Got it" → `dismissed`, so it cannot misdescribe the
record.

Started via `provideAppInitializer` rather than mounted in app.html: a CDK
overlay is not a layout element, and nothing else would ever inject the
service. Same shape as ThemeService. It also means this PR does not touch the
app shell, so it does not conflict with PR-4.

Body uses `.message-block`, not `prose` — the typography plugin is not
installed, so the older user-menu-link-modal's classes are inert and strip
list markers. Sanitization stays on (§D10): `admin.announcements` is
delegable, so this body may be authored by a non-admin and reaches every user.

Verified end to end against dev data with a local app-api. With `requiresAck`:
opens on load, no ✕, Escape and backdrop clicks leave it open, and the button
writes `acknowledged` at rank 3 — upgrading the `seen` row in place rather
than duplicating it. Without it: ✕ and Escape both write `dismissed` at rank
2. Afterwards the server returns `modal: null` while both panel entries
survive (§D1/§D2). Light, dark, and 375px with no horizontal overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell force-pushed the feature/announcement-modal branch from 5d9d91e to b54d2d6 Compare September 6, 2026 15:00
@philmerrell
philmerrell merged commit a631f3b into develop Sep 6, 2026
4 checks passed
@philmerrell
philmerrell deleted the feature/announcement-modal branch September 6, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant