Skip to content

feat(notices): validated contract for host-supplied notices - #2666

Open
camielvs wants to merge 4 commits into
banners-01-markdownfrom
banners-03-contract
Open

feat(notices): validated contract for host-supplied notices#2666
camielvs wants to merge 4 commits into
banners-01-markdownfrom
banners-03-contract

Conversation

@camielvs

@camielvs camielvs commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Why

The announcements this stack replaces are already host-installed: AnnouncementBanners reads window.__TANGLE_ANNOUNCEMENTS__ directly, and src/config/announcements.ts is nothing but the declare global for it. So the mechanism isn't new here — what's new is that it's validated, typed at the boundary as unknown, and can update after boot.

The existing global has four problems, all of them things a host page can trigger today:

  • No validation. variant: announcement.variant ?? "info" passes any string through to variantStyles[variant].container — a typo'd variant is a TypeError in render, and an entry with no title renders an empty banner.
  • Read during render, with new Date(). Both a mutable global read and a clock read happen inside the component body, which is why AnnouncementBanners.tsx can't be enabled for the React Compiler.
  • No live update. useState(getDismissedIds) snapshots once, and the global is read with no subscription — a host that installs or replaces its data after the app mounts is invisible until a reload. There's no event, so it's a race the host can't win.
  • Body is plain text. No links, no emphasis, no lists.

What you get

No behaviour change. This PR adds src/config/notices.ts and its documentation; nothing imports it yet. __TANGLE_ANNOUNCEMENTS__ and its banners are still the only thing on screen — they're replaced in #2667.

window.__TANGLE_NOTICE_SOURCE__ = {
  version: 1,
  getSnapshot: () => notices,
  subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
  refresh: () => fetchNotices(),   // optional
};
window.dispatchEvent(new CustomEvent("tangle:notice-source"));

With nothing installed the app shows nothing, exactly as it does today with the global unset. src/config/NOTICES.md is the reference for host authors.

Reviewer notes

This is a trust boundary, which is where the code goes. The global is typed unknown and everything is proven before it reaches a component: one type guard for the source, then per-field reads. The rules, all tested:

  • version must be exactly 1 — a source declaring 2 is ignored wholesale, so a future contract can't half-render through this one.
  • id required (a finite number is coerced); title required and non-blank. An entry failing either is dropped rather than rendered empty.
  • variant falls back to info for anything unrecognised — this is the fix for the variantStyles[variant] crash above.
  • body is Markdown and renders through UntrustedMarkdown from feat(markdown): shared Markdown renderer #2664. Whitespace is preserved because it's significant.
  • action.url must be absolute http(s); javascript: and relative URLs drop the action and keep the notice.
  • Duplicate ids collapse to the first; at most 20 entries are read per snapshot.
  • A getSnapshot that throws is treated as "no notices" rather than propagating.

Two things worth a look because they're subtle rather than long:

Snapshot identity. getSnapshot is called on every render, and a host that maps/spreads its array returns a new reference each time — which would loop useSyncExternalStore. The store compares serialised content and returns the previous frozen array when it's unchanged. Tested with a rebuild-per-read source.

The required event. The app subscribes when it mounts, which may be before the host has installed anything, so subscribe alone would race. tangle:notice-source is what tells the store to (re)bind — documented as required, tested for late installation, and safe to dispatch again after replacing the global. Without it the options are a silent race (today's behaviour) or blocking app boot on the host's fetch.

Field-level differences from Announcement: expiresAt is gone — a host that can update its snapshot expires a notice by dropping it, rather than shipping an expiry the app has to evaluate with a clock read in render. action is added. id/title/body/variant/dismissible carry over.

Where this sits

PR
1 #2664 shared Markdown renderer
2 #2666 validated host contract (you are here)
3 #2667 notice banners on the dashboard home — announcement parity
4 #2668 notices button in the header
5 #2681 hide a banner without retiring it

camielvs commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

🎩 Preview

A preview build has been created at: banners-03-contract/46d176a

Comment thread src/config/banners.ts Outdated
Comment thread src/config/banners.ts Outdated
Comment thread src/config/notices.ts
Comment thread src/config/bannerTestSource.ts Outdated
Comment thread knip.json Outdated
Comment thread src/config/banners.ts Outdated
@camielvs
camielvs force-pushed the banners-02-primitives branch from dc471e3 to ac56d12 Compare August 26, 2026 23:45
@camielvs
camielvs force-pushed the banners-03-contract branch from a100783 to eb0c6de Compare August 26, 2026 23:45
@camielvs
camielvs force-pushed the banners-02-primitives branch from ac56d12 to 3a5d8f1 Compare August 27, 2026 01:18
@camielvs
camielvs force-pushed the banners-03-contract branch from d9ad21b to 4fb20f2 Compare August 27, 2026 01:18
Comment thread src/config/notices.ts

declare global {
interface Window {
__TANGLE_NOTICE_SOURCE__?: unknown;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is typed to unknown but guarded by isNoticeSource, so only TangleNotice will work. TangleNotice is Tangle-UI's expectation of the blind contract and thus this is the shape the source will need to provide.

@camielvs
camielvs marked this pull request as ready for review August 28, 2026 00:02
@camielvs
camielvs requested a review from a team as a code owner August 28, 2026 00:02
@camielvs
camielvs force-pushed the banners-02-primitives branch from 3a5d8f1 to e204342 Compare August 28, 2026 23:30
@camielvs
camielvs force-pushed the banners-03-contract branch from 4fb20f2 to 189fa37 Compare August 28, 2026 23:30
camielvs and others added 4 commits August 28, 2026 16:34
Adds the boundary an embedding page talks to: it may install a notice source on
the window, and this module reads it defensively. Every field is validated and
normalised, a malformed entry is dropped rather than trusted, and a source that
declares an unsupported version is ignored wholesale.

Headless and unreferenced — nothing renders these yet. Reviewable on its own,
with the test file as the argument for what the boundary does and does not let
through.
Collapse duplicate ids to the first occurrence and cap a snapshot at 20
entries, so a malformed host cannot produce duplicate React keys or
unbounded per-render validation work.

Freeze each banner rather than only the array — a mutated banner used to
poison the signature cache for the life of the page, since the signature
is computed before a consumer can reach the object.

Declare __TANGLE_BANNER_SOURCE__ as unknown, which is what it is, and the
cast in the test source helper disappears.

Add BANNERS.md documenting the host-facing contract, in particular that
the tangle:banner-source event is required after assigning the global.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Notices are the domain concept; banners are one of the two surfaces they
are shown on. Renames the config module, its global and event, and the
contract doc to match, before any host depends on the old names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A notice with no title renders as a card whose first line is blank, and the
publishing API this contract exists to serve rejects an empty title already.
Requiring one here means InfoBox needs no change to support the case.
@camielvs
camielvs changed the base branch from banners-02-primitives to graphite-base/2666 August 28, 2026 23:51
@camielvs
camielvs force-pushed the graphite-base/2666 branch from e204342 to b525bf9 Compare August 28, 2026 23:51
@camielvs
camielvs force-pushed the banners-03-contract branch from 189fa37 to 46d176a Compare August 28, 2026 23:51
@camielvs
camielvs changed the base branch from graphite-base/2666 to banners-01-markdown August 28, 2026 23:51
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