@the-portland-company/shell 0.76.15 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ ## 0.77.0
2
+
3
+ No theme control in the account menu. Specs cited: Reference/UI-Platform "Theme — light only (Dark Mode retired)" and "There is no theme control … Apps must not render any theme control"; Reference/Global-Rules "Theming — Light Only".
4
+
5
+ - **Breaking for the eye only.** `PolitogyAppFrame` no longer passes `theme` to the account menu, so the "Appearance" radio group (one option, "Light", since 0.73.0 made the theme light only) is gone from every app. `NavUserMenu.theme` stays optional for a caller that still passes it; nothing in the shell does.
6
+ - `./theme-preference` and `useThemePreference` are unchanged: they still resolve every stored preference to light and keep the cross-app cookie contract for the apps that import them.
7
+ - **Audit note (closeout 2026-10-12, item 1).** The "Known shell defect — dark theme restored in 0.68.0" paragraph in Reference/UI-Platform is stale: 0.73.0 (#116, 2026-10-06) removed dark rendering, `chrome.css` ships no `.dark` block (the remaining mentions are comments), and the pre-paint script hardcodes light. The runtime Brand token sheet (specs app, `lib/brand-authoring.ts`) still emits `.dark {}` blocks that nothing applies.
8
+
9
+ ## 0.76.18
10
+
11
+ Consolidates 0.76.16 and 0.76.17, neither of which reached npm (their publish gates failed). Specs cited: Global-Rules v38, UI-Platform v46, Specs/Left-Navigation (no Tribes row, §9.1), Specs/System-Communications §6.3 and §12.2 (Binding, 2026-10-03).
12
+
13
+ - **`NotificationsAdapter` (comms bell).** `src/notifications/adapter.ts`: `list(orgId)`, `markRead`, `archive`, `resolve`. The default reads `me/notifications` through the chrome proxy with the viewer's session (comms `GET /v1/me/notifications`, `POST /v1/me/notifications/:id/{read,archive,resolve}`, politogy-comms #14). Any failure (401, 404, network, malformed body) is an empty bell, never an error; owner actions resolve `false` instead of throwing. `NavChromeProvider` and `PolitogyAppChrome` take `notificationsAdapter`: omitted = comms (default); `null` = legacy contacts-shaped feed (replies, imports, quarantine). Alert rows read the server `read_at`; the badge is the server count.
14
+ - **Behavior change for consumers.** Apps that rely on the legacy contacts replies, imports or quarantine rows in the bell must pass `notificationsAdapter={null}`.
15
+ - **Tests.** `__tests__/notificationsAdapter.test.ts` (new). `__tests__/chromeFetchDedupe.test.tsx` counts `me/notifications`. `__tests__/chromeActions.test.tsx` pins its legacy `NotificationsAction` tests to `notificationsAdapter={null}`; this is what fixed the 8 CI-only failures that blocked the 0.76.16 publish gate.
16
+ - **Tribes.** No `tribes` registry row in this release. Tribes lives inside Contacts; a `tribes.politogyvrm.com` entry would point at an empty scaffold.
17
+ - **Tab containers are left-aligned and fluid** (Spencer, 2026-10-11: "all apps, all tabs, all screens"; Reference/Global-Rules "Tabs & UI Styling": the strip spans the full width of the content area, tabs keep their natural width, left-aligned). `HeaderTabBar` (deprecated) no longer stretches each tab with `flex-1`: tabs sit at natural width packed from the left on a full-width strip (40px hit area). `NavTabStrip` already met this (full-width block, `flex` with default start alignment, natural-width tabs) and is unchanged; `__tests__/tabsLeftAligned.test.tsx` (new) pins both so neither grows `mx-auto`, `justify-center` or a fixed width.
18
+
19
+ ## 0.76.17
20
+
21
+ Tooltips on the Spec tooltip theme, and the new-reply toast no longer paints over the bell's tooltip. Specs cited: Brand guideline "Tooltips point at their trigger" (`tooltips-point-at-their-trigger`, Spencer 2026-10-06), Reference/UI-Platform "Top-bar tooltips and panels - one shared treatment", Reference/Global-Rules "Tooltip on every tab / UX", tokens v150 `shellChrome.button.tooltipBg/Fg`.
22
+
23
+ - **`TopBarTooltip` is styled on the tooltip tokens.** Body, border and arrow use `--app-btn-tooltip-bg` (v150: #2F3136), title and description use `--app-btn-tooltip-fg` (#FFFFFF, description at 75% opacity). It was on the panel surface (`--panel-elevated` / `--line`), which is why it rendered as a light card. Opens on keyboard focus (`group-focus-visible/tbtip`) as well as hover, per the guideline.
24
+ - **Toast/tooltip overlap (a2p dashboard, reported 2026-10-11).** The new-reply toast and the bell's hover tooltip are both z-50 under the same trigger; hovering the bell while a toast was up painted the toast over "Alerts & Notifications". `TopBarPopover` takes `hideTooltip`; `NotificationsAction` passes it while the toast is visible and `FuelAction` while the low-fuel hint is up. One surface per trigger.
25
+ - **`HeaderTabBar` (deprecated) uses the shared tooltip** instead of Chakra's `Tooltip`; the shell no longer ships a second tooltip.
26
+ - Tests: `__tests__/tooltipTheme.test.tsx` (new).
27
+ - **Not changed:** `src/css/chrome.css` still carries the pre-v150 fallbacks (`--app-btn-tooltip-bg: #0F172A`, mode primaries). `npm run gen:tokens` against Specs v150 produces a 508-line diff including `--mode-relationship-primary` #D32F3B -> #2F3136, which is a Brand-token question, not this fix; the runtime token sheet (`ensureRuntimeBrandTokens`) already overrides every fallback live.
28
+ - **Still open:** `aria-describedby` from trigger to tooltip (guideline) is not wired; apps outside the chrome still use native `title=` attributes rather than `TopBarTooltip` (exported from `./native`).
29
+
30
+ ## 0.76.16
31
+
32
+ The shell's Notifications bell reads the comms feed through a `NotificationsAdapter`. Specs cited: Global-Rules v38, UI-Platform v46, Specs/Left-Navigation (no Tribes row, §9.1), Specs/System-Communications §6.3 and §12.2 (Binding, 2026-10-03).
33
+
34
+ - **`NotificationsAdapter` (bell).** New `src/notifications/adapter.ts`: `list(orgId)`, `markRead`, `archive`, `resolve`. The default reads `me/notifications` through the chrome proxy with the viewer's session, which is the comms `GET /v1/me/notifications` and `POST /v1/me/notifications/:id/{read,archive,resolve}` (politogy-comms #14). Any failure (401, 404, network, malformed body) is an empty bell, never an error state; owner actions resolve `false` instead of throwing.
35
+ - **Bell wiring.** `NavChromeProvider` and `PolitogyAppChrome` take `notificationsAdapter`. Omitted, the comms adapter is used. `null` keeps the legacy contacts-shaped feed (replies, imports, quarantine) for any app that needs it. Alert rows read the server `read_at` state; the badge is the server's single count.
36
+ - Exports: `createCommsNotificationsAdapter`, `normalizeNotificationsFeed`, `emptyNotificationsFeed` and the adapter types from the package root.
37
+ - Tests: `__tests__/notificationsAdapter.test.ts` (new); `__tests__/chromeFetchDedupe.test.tsx` now counts `me/notifications`. The 41 pre-existing localStorage/cookie failures are unchanged and not addressed.
38
+ - **Still open:** `subscribe` (realtime) is not implemented; the bell refreshes on open and after a mark-read. Resolve and archive are on the adapter but have no panel control yet.
39
+
1
40
  ## 0.76.15
2
41
 
3
42
  TITLEBAR: the chrome no longer draws the 56px page-title `<h1>` strip (for example "A2P Messaging" above the "CAMPAIGN MODE" eyebrow) for feature apps. The Design shows the page opening on its own eyebrow + H2, so `PolitogyAppFrame` now passes only core's own page title as `topBar.title`. The `title` grant is kept but inert; removing it is a follow-up.
@@ -415,91 +415,78 @@ function SpeculationRulesScript({ items }) {
415
415
  return null;
416
416
  }
417
417
 
418
- // src/theme-preference.ts
419
- var THEME_STORAGE_KEY = "politogy_theme";
420
- var THEME_COOKIE = "politogy_theme";
421
- var THEME_PENDING_COOKIE = "politogy_theme_pending";
422
- var VALID2 = ["light", "dark", "system"];
423
- function isThemePreference(v) {
424
- return typeof v === "string" && VALID2.includes(v);
425
- }
426
- function readThemeCookie() {
427
- if (typeof document === "undefined") return null;
428
- try {
429
- const m = document.cookie.match(
430
- new RegExp(`(?:^|;\\s*)${THEME_COOKIE}=([^;]+)`)
431
- );
432
- const v = m ? decodeURIComponent(m[1]) : null;
433
- return isThemePreference(v) ? v : null;
434
- } catch {
435
- return null;
436
- }
437
- }
438
- function writeThemeCookie(pref) {
439
- if (typeof document === "undefined" || typeof window === "undefined") return;
440
- const host = window.location.hostname;
441
- const onParentDomain = /(^|\.)politogyvrm\.com$/i.test(host);
442
- const domainAttr = onParentDomain ? "; Domain=.politogyvrm.com" : "";
443
- const secure = window.location.protocol === "https:" ? "; Secure" : "";
444
- document.cookie = `${THEME_COOKIE}=${encodeURIComponent(
445
- pref
446
- )}; Path=/; Max-Age=31536000; SameSite=Lax${domainAttr}${secure}`;
447
- }
448
- function writeSharedCookie(name, value, maxAgeSeconds) {
449
- if (typeof document === "undefined" || typeof window === "undefined") return;
450
- const host = window.location.hostname;
451
- const domainAttr = /(^|\.)politogyvrm\.com$/i.test(host) ? "; Domain=.politogyvrm.com" : "";
452
- const secure = window.location.protocol === "https:" ? "; Secure" : "";
453
- document.cookie = `${name}=${encodeURIComponent(
454
- value
455
- )}; Path=/; Max-Age=${maxAgeSeconds}; SameSite=Lax${domainAttr}${secure}`;
456
- }
457
- function markThemeChoicePending(pref) {
458
- writeSharedCookie(THEME_PENDING_COOKIE, pref, 31536e3);
459
- }
460
- function readPendingThemeChoice() {
461
- if (typeof document === "undefined") return null;
462
- try {
463
- const m = document.cookie.match(
464
- new RegExp(`(?:^|;\\s*)${THEME_PENDING_COOKIE}=([^;]+)`)
465
- );
466
- const v = m ? decodeURIComponent(m[1]) : null;
467
- return isThemePreference(v) ? v : null;
468
- } catch {
469
- return null;
418
+ // src/notifications/adapter.ts
419
+ function emptyNotificationsFeed() {
420
+ return { notifications: [], badge: 0 };
421
+ }
422
+ function asString(value) {
423
+ return typeof value === "string" ? value : null;
424
+ }
425
+ function normalizeNotificationsFeed(raw) {
426
+ if (!raw || typeof raw !== "object") return emptyNotificationsFeed();
427
+ const body = raw;
428
+ const rows = Array.isArray(body.notifications) ? body.notifications : [];
429
+ const notifications = [];
430
+ for (const row of rows) {
431
+ if (!row || typeof row !== "object") continue;
432
+ const r = row;
433
+ const id = asString(r.id);
434
+ if (!id || asString(r.archived_at)) continue;
435
+ notifications.push({
436
+ id,
437
+ kind: r.kind === "alert" ? "alert" : "notification",
438
+ title: asString(r.title) ?? "",
439
+ body: asString(r.body) ?? "",
440
+ icon: asString(r.icon),
441
+ link: asString(r.link),
442
+ readAt: asString(r.read_at),
443
+ orgId: asString(r.org_id),
444
+ createdAt: asString(r.created_at)
445
+ });
470
446
  }
447
+ const badge = typeof body.badge === "number" && Number.isFinite(body.badge) ? Math.max(0, Math.floor(body.badge)) : notifications.filter((n) => n.kind === "alert" || n.readAt === null).length;
448
+ return { notifications, badge };
471
449
  }
472
- function clearPendingThemeChoice() {
473
- writeSharedCookie(THEME_PENDING_COOKIE, "", 0);
474
- }
475
- function resolveTheme(_pref) {
476
- return "light";
450
+ function createCommsNotificationsAdapter(fetchJson) {
451
+ const act = async (id, action) => {
452
+ if (!fetchJson || !id) return false;
453
+ try {
454
+ await fetchJson(`me/notifications/${encodeURIComponent(id)}/${action}`, { method: "POST" });
455
+ return true;
456
+ } catch {
457
+ return false;
458
+ }
459
+ };
460
+ return {
461
+ async list(orgId) {
462
+ if (!fetchJson) return emptyNotificationsFeed();
463
+ try {
464
+ const query = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
465
+ return normalizeNotificationsFeed(await fetchJson(`me/notifications${query}`));
466
+ } catch {
467
+ return emptyNotificationsFeed();
468
+ }
469
+ },
470
+ markRead: (id) => act(id, "read"),
471
+ archive: (id) => act(id, "archive"),
472
+ resolve: (id) => act(id, "resolve")
473
+ };
477
474
  }
478
- function applyThemePreference(pref) {
479
- if (typeof document === "undefined") return;
480
- const resolved = resolveTheme();
481
- const d = document.documentElement;
482
- d.classList.toggle("dark", resolved === "dark");
483
- try {
484
- d.setAttribute("data-theme", resolved);
485
- d.style.colorScheme = resolved;
486
- } catch {
487
- }
475
+ function notificationsFeedToPayload(feed, orgId) {
476
+ return {
477
+ organizationId: orgId ?? "",
478
+ alerts: feed.notifications.map((n) => ({
479
+ id: n.id,
480
+ title: n.title,
481
+ description: n.body,
482
+ href: n.link,
483
+ kind: n.kind,
484
+ read: n.readAt !== null
485
+ })),
486
+ replies: [],
487
+ badge: feed.badge
488
+ };
488
489
  }
489
- `(function(){try{
490
- var KEY=${JSON.stringify(THEME_STORAGE_KEY)};
491
- var VALID=["light","dark","system"];
492
- function fromCookie(){try{var m=document.cookie.match(/(?:^|;\\s*)${THEME_COOKIE}=([^;]+)/);return m?decodeURIComponent(m[1]):null;}catch(e){return null;}}
493
- var cookie=fromCookie();
494
- var ls=null;try{ls=localStorage.getItem(KEY);}catch(e){}
495
- var pref=cookie||ls||"system";
496
- if(VALID.indexOf(pref)===-1)pref="system";
497
- if(cookie&&cookie!==ls){try{localStorage.setItem(KEY,cookie);}catch(e){}}
498
- var dark=false;
499
- var d=document.documentElement;
500
- if(dark)d.classList.add("dark");else d.classList.remove("dark");
501
- try{d.setAttribute("data-theme",dark?"dark":"light");d.style.colorScheme=dark?"dark":"light";}catch(e){}
502
- }catch(e){}})();`;
503
490
  var CORE_APP_ORIGIN = "https://app.politogyvrm.com";
504
491
  var CORE_BILLING_PATH = "/organization-settings/subscription-billing/text-email";
505
492
  var CORE_BILLING_URL = `${CORE_APP_ORIGIN}${CORE_BILLING_PATH}`;
@@ -558,17 +545,17 @@ function TopBarTooltip({
558
545
  "span",
559
546
  {
560
547
  role: "tooltip",
561
- className: "pointer-events-none absolute left-1/2 top-full z-50 mt-2 hidden w-max max-w-56 -translate-x-1/2 flex-col rounded-lg border border-[color:var(--line)] bg-[color:var(--panel-elevated)] px-3 py-2 text-left shadow-[var(--shadow-soft)] group-hover/tbtip:flex",
548
+ className: "pointer-events-none absolute left-1/2 top-full z-50 mt-2 hidden w-max max-w-56 -translate-x-1/2 flex-col rounded-lg border border-[color:var(--app-btn-tooltip-bg,#2F3136)] bg-[color:var(--app-btn-tooltip-bg,#2F3136)] px-3 py-2 text-left shadow-[var(--shadow-soft)] group-hover/tbtip:flex group-focus-visible/tbtip:flex",
562
549
  children: [
563
550
  /* @__PURE__ */ jsxRuntime.jsx(
564
551
  "span",
565
552
  {
566
553
  "aria-hidden": true,
567
- className: "absolute -top-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-l border-t border-[color:var(--line)] bg-[color:var(--panel-elevated)]"
554
+ className: "absolute -top-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-l border-t border-[color:var(--app-btn-tooltip-bg,#2F3136)] bg-[color:var(--app-btn-tooltip-bg,#2F3136)]"
568
555
  }
569
556
  ),
570
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold text-[color:var(--foreground)]", children: title }),
571
- description ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mt-0.5 text-[11px] leading-snug text-[color:var(--muted)]", children: description }) : null
557
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold text-[color:var(--app-btn-tooltip-fg,#FFFFFF)]", children: title }),
558
+ description ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "mt-0.5 text-[11px] leading-snug text-[color:var(--app-btn-tooltip-fg,#FFFFFF)] opacity-75", children: description }) : null
572
559
  ]
573
560
  }
574
561
  );
@@ -652,6 +639,7 @@ function createChromeFetchCache(getFetchJson) {
652
639
  var NavChromeContext = react.createContext(null);
653
640
  function NavChromeProvider({
654
641
  fetchJson,
642
+ notificationsAdapter,
655
643
  children
656
644
  }) {
657
645
  const fetchJsonRef = react.useRef(fetchJson);
@@ -661,6 +649,13 @@ function NavChromeProvider({
661
649
  cacheRef.current = createChromeFetchCache(() => fetchJsonRef.current);
662
650
  }
663
651
  const { dedupedFetch, invalidate: invalidateChromeCache } = cacheRef.current;
652
+ const defaultAdapterRef = react.useRef(null);
653
+ if (!defaultAdapterRef.current) {
654
+ defaultAdapterRef.current = createCommsNotificationsAdapter(
655
+ (path, init) => fetchJsonRef.current(path, init)
656
+ );
657
+ }
658
+ const adapter = notificationsAdapter === void 0 ? defaultAdapterRef.current : notificationsAdapter;
664
659
  const [me, setMe] = react.useState(null);
665
660
  const [meLoaded, setMeLoaded] = react.useState(false);
666
661
  react.useEffect(() => {
@@ -675,7 +670,13 @@ function NavChromeProvider({
675
670
  cancelled = true;
676
671
  };
677
672
  }, [dedupedFetch]);
678
- return /* @__PURE__ */ jsxRuntime.jsx(NavChromeContext.Provider, { value: { fetchJson, dedupedFetch, invalidateChromeCache, me, meLoaded }, children });
673
+ return /* @__PURE__ */ jsxRuntime.jsx(
674
+ NavChromeContext.Provider,
675
+ {
676
+ value: { fetchJson, dedupedFetch, invalidateChromeCache, me, meLoaded, notificationsAdapter: adapter },
677
+ children
678
+ }
679
+ );
679
680
  }
680
681
  function useChromeMe() {
681
682
  const ctx = react.useContext(NavChromeContext);
@@ -756,6 +757,7 @@ function TopBarPopover({
756
757
  onOpen,
757
758
  openToken,
758
759
  onOpenChange,
760
+ hideTooltip = false,
759
761
  children,
760
762
  panelClassName
761
763
  }) {
@@ -812,7 +814,7 @@ function TopBarPopover({
812
814
  children: [
813
815
  /* @__PURE__ */ jsxRuntime.jsx(SlotGlyph, { slot, fallback: fallbackIcon }),
814
816
  typeof badge === "number" ? /* @__PURE__ */ jsxRuntime.jsx(TopBarCountBadge, { count: badge }) : null,
815
- !open ? /* @__PURE__ */ jsxRuntime.jsx(TopBarTooltip, { title: label, description }) : null
817
+ !open && !hideTooltip ? /* @__PURE__ */ jsxRuntime.jsx(TopBarTooltip, { title: label, description }) : null
816
818
  ]
817
819
  }
818
820
  ),
@@ -950,7 +952,11 @@ function subscribeLowFuelHintVisible(onChange) {
950
952
  return () => lowFuelHintListeners.delete(onChange);
951
953
  }
952
954
  function useLowFuelHintVisible() {
953
- return react.useSyncExternalStore(subscribeLowFuelHintVisible, () => lowFuelHintVisible);
955
+ return react.useSyncExternalStore(
956
+ subscribeLowFuelHintVisible,
957
+ () => lowFuelHintVisible,
958
+ () => false
959
+ );
954
960
  }
955
961
  var LOW_FUEL_HINT_CLEARANCE_PX = 96;
956
962
  function LowFuelHint({ lowFuel, orgId }) {
@@ -1055,6 +1061,7 @@ function FuelAction({ orgId }) {
1055
1061
  };
1056
1062
  }, [dedupedFetch, orgId]);
1057
1063
  const low = !!fuel && (fuel.signal.lowFuel || fuel.broadcast.lowFuel);
1064
+ const lowFuelHintUp = useLowFuelHintVisible();
1058
1065
  return (
1059
1066
  // The nudge anchors to the trigger, so it sits OUTSIDE the popover panel in
1060
1067
  // its own relative wrapper (same shape the VRM widget used).
@@ -1067,6 +1074,7 @@ function FuelAction({ orgId }) {
1067
1074
  slot: "comms-fuel",
1068
1075
  fallbackIcon: BatteryGlyph,
1069
1076
  tone: low ? "warning" : "default",
1077
+ hideTooltip: lowFuelHintUp,
1070
1078
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
1071
1079
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Communications Fuel" }),
1072
1080
  error || !loading && !fuel ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Fuel balance is unavailable right now." }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
@@ -1256,7 +1264,7 @@ function NewReplyToast({
1256
1264
  );
1257
1265
  }
1258
1266
  function NotificationsAction({ orgId }) {
1259
- const { dedupedFetch } = useNavChrome("NotificationsAction");
1267
+ const { dedupedFetch, notificationsAdapter } = useNavChrome("NotificationsAction");
1260
1268
  const [payload, setPayload] = react.useState(null);
1261
1269
  const [loading, setLoading] = react.useState(true);
1262
1270
  const [error, setError] = react.useState(false);
@@ -1265,6 +1273,7 @@ function NotificationsAction({ orgId }) {
1265
1273
  const [toastsMuted, setToastsMuted] = react.useState(() => readToastsMuted());
1266
1274
  const [openToken, setOpenToken] = react.useState(0);
1267
1275
  const [panelOpen, setPanelOpen] = react.useState(false);
1276
+ const [reloadToken, setReloadToken] = react.useState(0);
1268
1277
  const anyPanelOpen = useAnyTopBarPanelOpen();
1269
1278
  react.useEffect(() => {
1270
1279
  setNotif(readNotifState(orgId));
@@ -1276,6 +1285,19 @@ function NotificationsAction({ orgId }) {
1276
1285
  setLoading(false);
1277
1286
  return;
1278
1287
  }
1288
+ if (notificationsAdapter) {
1289
+ setError(false);
1290
+ notificationsAdapter.list(orgId).then((feed) => {
1291
+ if (!cancelled) setPayload(notificationsFeedToPayload(feed, orgId));
1292
+ }).catch(() => {
1293
+ if (!cancelled) setPayload(notificationsFeedToPayload(emptyNotificationsFeed(), orgId));
1294
+ }).finally(() => {
1295
+ if (!cancelled) setLoading(false);
1296
+ });
1297
+ return () => {
1298
+ cancelled = true;
1299
+ };
1300
+ }
1279
1301
  setLoading(true);
1280
1302
  setError(false);
1281
1303
  setPayload(null);
@@ -1293,9 +1315,14 @@ function NotificationsAction({ orgId }) {
1293
1315
  return () => {
1294
1316
  cancelled = true;
1295
1317
  };
1296
- }, [dedupedFetch, orgId]);
1318
+ }, [dedupedFetch, notificationsAdapter, orgId, reloadToken]);
1297
1319
  const markRead = react.useCallback(
1298
1320
  (id) => {
1321
+ if (notificationsAdapter) {
1322
+ void notificationsAdapter.markRead(id).then((ok) => {
1323
+ if (ok) setReloadToken((t) => t + 1);
1324
+ });
1325
+ }
1299
1326
  setNotif((prev) => {
1300
1327
  if (prev.readAlertIds.includes(id)) return prev;
1301
1328
  const next = { ...prev, readAlertIds: [...prev.readAlertIds, id] };
@@ -1303,7 +1330,7 @@ function NotificationsAction({ orgId }) {
1303
1330
  return next;
1304
1331
  });
1305
1332
  },
1306
- [orgId]
1333
+ [orgId, notificationsAdapter]
1307
1334
  );
1308
1335
  const markUnread = react.useCallback(
1309
1336
  (id) => {
@@ -1373,14 +1400,16 @@ function NotificationsAction({ orgId }) {
1373
1400
  }
1374
1401
  setOpenToken((t) => t + 1);
1375
1402
  }, [toastReply]);
1376
- const unreadAlertCount = alerts.filter((a) => !notif.readAlertIds.includes(a.id)).length;
1403
+ const isAlertRead = (a) => a.read === true || notif.readAlertIds.includes(a.id);
1404
+ const unreadAlertCount = alerts.filter((a) => !isAlertRead(a)).length;
1377
1405
  const unseenReplyCount = replies.filter((r) => {
1378
1406
  if (!notif.lastSeenAt) return true;
1379
1407
  if (!r.createdAt) return false;
1380
1408
  return r.createdAt > notif.lastSeenAt;
1381
1409
  }).length;
1382
- const badgeCount = unreadAlertCount + unseenReplyCount;
1410
+ const badgeCount = notificationsAdapter && payload?.badge !== void 0 ? payload.badge : unreadAlertCount + unseenReplyCount;
1383
1411
  const isEmpty = alerts.length === 0 && replies.length === 0 && imports.length === 0 && quarantined.length === 0;
1412
+ const toastVisible = toastReply !== null && !panelOpen && !anyPanelOpen;
1384
1413
  return (
1385
1414
  // Relative wrapper so the new-reply toast anchors to the bell trigger,
1386
1415
  // outside the popover panel (same pattern as the fuel low-fuel nudge).
@@ -1396,6 +1425,7 @@ function NotificationsAction({ orgId }) {
1396
1425
  onOpen,
1397
1426
  openToken,
1398
1427
  onOpenChange: setPanelOpen,
1428
+ hideTooltip: toastVisible,
1399
1429
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-3", children: [
1400
1430
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [
1401
1431
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]", children: "Notifications" }),
@@ -1415,7 +1445,7 @@ function NotificationsAction({ orgId }) {
1415
1445
  ] }) : error ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-[color:var(--muted)]", children: "Notifications are unavailable right now." }) : isEmpty ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "py-1 text-sm text-[color:var(--muted)]", children: "You\u2019re all caught up." }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex max-h-80 flex-col gap-2 overflow-y-auto", children: [
1416
1446
  alerts.length > 0 || replies.length > 0 ? /* @__PURE__ */ jsxRuntime.jsxs("ul", { className: "flex flex-col gap-1", children: [
1417
1447
  alerts.map((a) => {
1418
- const isRead = notif.readAlertIds.includes(a.id);
1448
+ const isRead = isAlertRead(a);
1419
1449
  return /* @__PURE__ */ jsxRuntime.jsx("li", { className: isRead ? "opacity-50" : void 0, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-1 rounded-lg px-2 py-2 hover:bg-[color:var(--panel)]", children: [
1420
1450
  /* @__PURE__ */ jsxRuntime.jsxs(
1421
1451
  "a",
@@ -1434,7 +1464,7 @@ function NotificationsAction({ orgId }) {
1434
1464
  {
1435
1465
  type: "button",
1436
1466
  "aria-label": isRead ? "Mark as unread" : "Mark as read",
1437
- onClick: () => isRead ? markUnread(a.id) : markRead(a.id),
1467
+ onClick: () => isRead && !notificationsAdapter ? markUnread(a.id) : markRead(a.id),
1438
1468
  className: "mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[color:var(--muted)] transition hover:bg-[color:var(--panel-elevated)] hover:text-[color:var(--foreground)]",
1439
1469
  children: /* @__PURE__ */ jsxRuntime.jsx(CheckGlyph, {})
1440
1470
  }
@@ -1493,7 +1523,7 @@ function NotificationsAction({ orgId }) {
1493
1523
  ] })
1494
1524
  }
1495
1525
  ),
1496
- toastReply && !panelOpen && !anyPanelOpen ? /* @__PURE__ */ jsxRuntime.jsx(
1526
+ toastVisible && toastReply ? /* @__PURE__ */ jsxRuntime.jsx(
1497
1527
  NewReplyToast,
1498
1528
  {
1499
1529
  reply: toastReply,
@@ -2085,102 +2115,6 @@ var NAV_DISPLAY_OPTIONS = [
2085
2115
  { value: "tabs", label: "Tabs Only" },
2086
2116
  { value: "both", label: "Both" }
2087
2117
  ];
2088
- function useThemePreference() {
2089
- const ctx = react.useContext(NavChromeContext);
2090
- const fetchJson = ctx?.fetchJson ?? null;
2091
- const [value, setValueState] = react.useState("system");
2092
- const reconciled = react.useRef(false);
2093
- react.useEffect(() => {
2094
- const cookie = readThemeCookie();
2095
- let ls = null;
2096
- try {
2097
- ls = localStorage.getItem(THEME_STORAGE_KEY);
2098
- } catch {
2099
- }
2100
- const pref = cookie ?? (isThemePreference(ls) ? ls : null) ?? "system";
2101
- setValueState(pref);
2102
- applyThemePreference();
2103
- }, []);
2104
- const meCtx = react.useContext(NavChromeContext);
2105
- react.useEffect(() => {
2106
- if (reconciled.current) return;
2107
- const pending = readPendingThemeChoice();
2108
- if (pending) {
2109
- if (meCtx?.me) reconciled.current = true;
2110
- setValueState(pending);
2111
- applyThemePreference();
2112
- writeThemeCookie(pending);
2113
- try {
2114
- localStorage.setItem(THEME_STORAGE_KEY, pending);
2115
- } catch {
2116
- }
2117
- if (!meCtx?.me) return;
2118
- fetchJson?.("me/preferences", {
2119
- method: "PATCH",
2120
- headers: { "Content-Type": "application/json" },
2121
- body: JSON.stringify({ themePreference: pending })
2122
- }).then(() => clearPendingThemeChoice()).catch(() => {
2123
- });
2124
- return;
2125
- }
2126
- const pref = meCtx?.me?.themePreference;
2127
- if (isThemePreference(pref)) {
2128
- reconciled.current = true;
2129
- setValueState(pref);
2130
- applyThemePreference();
2131
- writeThemeCookie(pref);
2132
- try {
2133
- localStorage.setItem(THEME_STORAGE_KEY, pref);
2134
- } catch {
2135
- }
2136
- }
2137
- }, [meCtx?.me]);
2138
- react.useEffect(() => {
2139
- const onStorage = (e) => {
2140
- if (e.key === THEME_STORAGE_KEY && isThemePreference(e.newValue)) {
2141
- setValueState(e.newValue);
2142
- applyThemePreference(e.newValue);
2143
- }
2144
- };
2145
- const onWake = () => {
2146
- const cookie = readThemeCookie();
2147
- if (cookie) {
2148
- setValueState((prev) => {
2149
- if (cookie !== prev) applyThemePreference();
2150
- return cookie;
2151
- });
2152
- }
2153
- };
2154
- window.addEventListener("storage", onStorage);
2155
- window.addEventListener("focus", onWake);
2156
- document.addEventListener("visibilitychange", onWake);
2157
- return () => {
2158
- window.removeEventListener("storage", onStorage);
2159
- window.removeEventListener("focus", onWake);
2160
- document.removeEventListener("visibilitychange", onWake);
2161
- };
2162
- }, []);
2163
- const setValue = react.useCallback(
2164
- (next) => {
2165
- setValueState(next);
2166
- applyThemePreference();
2167
- writeThemeCookie(next);
2168
- try {
2169
- localStorage.setItem(THEME_STORAGE_KEY, next);
2170
- } catch {
2171
- }
2172
- markThemeChoicePending(next);
2173
- fetchJson?.("me/preferences", {
2174
- method: "PATCH",
2175
- headers: { "Content-Type": "application/json" },
2176
- body: JSON.stringify({ themePreference: next })
2177
- }).then(() => clearPendingThemeChoice()).catch(() => {
2178
- });
2179
- },
2180
- [fetchJson]
2181
- );
2182
- return { value, setValue };
2183
- }
2184
2118
  var standardNavIcons = {
2185
2119
  home: lu.LuHouse,
2186
2120
  users: lu.LuUsers,
@@ -5388,6 +5322,7 @@ function PolitogyAppChrome({
5388
5322
  storagePrefix,
5389
5323
  proxyBasePath = "/api/chrome",
5390
5324
  fetchJson: fetchJsonOverride,
5325
+ notificationsAdapter,
5391
5326
  devNotes,
5392
5327
  org,
5393
5328
  tokenOrg,
@@ -5419,7 +5354,7 @@ function PolitogyAppChrome({
5419
5354
  children
5420
5355
  }
5421
5356
  );
5422
- return /* @__PURE__ */ jsxRuntime.jsx(NavChromeProvider, { fetchJson, children: tokenOrg ? /* @__PURE__ */ jsxRuntime.jsx(TokenChromeStateProvider, { tokenOrg, children: devNotesTree }) : /* @__PURE__ */ jsxRuntime.jsx(ChromeStateProvider, { controlledOrg: org, children: devNotesTree }) });
5357
+ return /* @__PURE__ */ jsxRuntime.jsx(NavChromeProvider, { fetchJson, notificationsAdapter, children: tokenOrg ? /* @__PURE__ */ jsxRuntime.jsx(TokenChromeStateProvider, { tokenOrg, children: devNotesTree }) : /* @__PURE__ */ jsxRuntime.jsx(ChromeStateProvider, { controlledOrg: org, children: devNotesTree }) });
5423
5358
  }
5424
5359
  function ChromeTopBarActions() {
5425
5360
  const state = useChromeState();
@@ -5480,7 +5415,6 @@ function useChromeUserMenu({
5480
5415
  email
5481
5416
  }) {
5482
5417
  const state = useChromeState();
5483
- const theme = useThemePreference();
5484
5418
  const activeOrg = state?.orgs.find((o) => o.id === state.orgId) ?? null;
5485
5419
  const orgSuffix = activeOrg ? `?org=${activeOrg.id}` : "";
5486
5420
  const coreBase = getCoreBaseUrl();
@@ -5491,7 +5425,9 @@ function useChromeUserMenu({
5491
5425
  roles: (state?.orgs ?? []).filter((o) => o.rawRole || o.role).map((o) => ({ role: o.rawRole ?? o.role, orgName: o.name })),
5492
5426
  accounts: [{ id: userId, name: userName, email, avatarUrl: null, active: true }],
5493
5427
  addAccountHref: `${coreBase}/my-profile#accounts`,
5494
- theme: { value: theme.value, onChange: theme.setValue },
5428
+ // No theme control: the product is light only (Reference/UI-Platform
5429
+ // "Theme — light only"; shell 0.73.0 stopped applying dark). The account
5430
+ // menu rendered an "Appearance" radio with one option until 0.77.0.
5495
5431
  navDisplay: state ? { value: state.navDisplay, onChange: state.setNavDisplay } : void 0
5496
5432
  };
5497
5433
  }