@flamingo-stack/openframe-frontend-core 0.0.404 → 0.0.405-snapshot.20260711081601

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.
Files changed (148) hide show
  1. package/dist/{chunk-HBO2DMX4.js → chunk-3KZXOLFK.js} +3 -3
  2. package/dist/{chunk-RNKXG55J.js → chunk-6WMVBCNA.js} +4 -4
  3. package/dist/{chunk-A636Y7GU.js → chunk-7RVSGGO6.js} +5 -5
  4. package/dist/{chunk-YXOWUHGQ.js → chunk-AYFWYOL6.js} +6 -6
  5. package/dist/{chunk-GA5AN24T.cjs → chunk-BX3R44MR.cjs} +2 -2
  6. package/dist/chunk-BX3R44MR.cjs.map +1 -0
  7. package/dist/{chunk-GYFUGMNM.js → chunk-C3NPMZBI.js} +2 -2
  8. package/dist/{chunk-J4XR6YYZ.cjs → chunk-CCIBZ7TO.cjs} +10 -6
  9. package/dist/chunk-CCIBZ7TO.cjs.map +1 -0
  10. package/dist/{chunk-6MO5HF6P.cjs → chunk-CF3LBCNI.cjs} +32 -32
  11. package/dist/{chunk-6MO5HF6P.cjs.map → chunk-CF3LBCNI.cjs.map} +1 -1
  12. package/dist/{chunk-S4ZHBKGS.cjs → chunk-COERBDG4.cjs} +73 -73
  13. package/dist/{chunk-S4ZHBKGS.cjs.map → chunk-COERBDG4.cjs.map} +1 -1
  14. package/dist/{chunk-DH6HTYOV.js → chunk-E6YHDT3D.js} +6 -2
  15. package/dist/chunk-E6YHDT3D.js.map +1 -0
  16. package/dist/{chunk-OJ4HFM3I.cjs → chunk-GNS65HNC.cjs} +18 -18
  17. package/dist/{chunk-OJ4HFM3I.cjs.map → chunk-GNS65HNC.cjs.map} +1 -1
  18. package/dist/{chunk-G4YLKA7O.cjs → chunk-HJJXNMM7.cjs} +5 -5
  19. package/dist/{chunk-G4YLKA7O.cjs.map → chunk-HJJXNMM7.cjs.map} +1 -1
  20. package/dist/{chunk-TBXE5S6P.cjs → chunk-ISYVNPRT.cjs} +40 -40
  21. package/dist/{chunk-TBXE5S6P.cjs.map → chunk-ISYVNPRT.cjs.map} +1 -1
  22. package/dist/{chunk-CRUPO3MR.cjs → chunk-JAPP2F4M.cjs} +93 -93
  23. package/dist/{chunk-CRUPO3MR.cjs.map → chunk-JAPP2F4M.cjs.map} +1 -1
  24. package/dist/{chunk-G3QYCV63.cjs → chunk-LL4QQSAD.cjs} +500 -441
  25. package/dist/chunk-LL4QQSAD.cjs.map +1 -0
  26. package/dist/{chunk-YORONSXG.js → chunk-LSPBPUQM.js} +3 -3
  27. package/dist/{chunk-AMPQKNAX.js → chunk-OATWTTCI.js} +6 -6
  28. package/dist/{chunk-FFWIUNOT.cjs → chunk-P43ZHPM6.cjs} +40 -40
  29. package/dist/{chunk-FFWIUNOT.cjs.map → chunk-P43ZHPM6.cjs.map} +1 -1
  30. package/dist/{chunk-MTBFE3JF.js → chunk-PNJ5LMVK.js} +16 -2
  31. package/dist/chunk-PNJ5LMVK.js.map +1 -0
  32. package/dist/{chunk-R6SMQ2CO.js → chunk-PQN5R5II.js} +6 -6
  33. package/dist/{chunk-Q3AO2QWK.cjs → chunk-QAFRLO5N.cjs} +18 -4
  34. package/dist/chunk-QAFRLO5N.cjs.map +1 -0
  35. package/dist/{chunk-PFQQ5H6G.cjs → chunk-SF4G7M6U.cjs} +19 -19
  36. package/dist/{chunk-PFQQ5H6G.cjs.map → chunk-SF4G7M6U.cjs.map} +1 -1
  37. package/dist/{chunk-TTUQSOBL.js → chunk-SOK32D6C.js} +62 -3
  38. package/dist/chunk-SOK32D6C.js.map +1 -0
  39. package/dist/{chunk-5Z5GXXRU.js → chunk-TFDNJTBX.js} +4 -4
  40. package/dist/{chunk-QXAHXMPD.cjs → chunk-VANFD4FX.cjs} +12 -12
  41. package/dist/{chunk-QXAHXMPD.cjs.map → chunk-VANFD4FX.cjs.map} +1 -1
  42. package/dist/{chunk-KVB7SHXY.cjs → chunk-VIB4SGCD.cjs} +12 -12
  43. package/dist/{chunk-KVB7SHXY.cjs.map → chunk-VIB4SGCD.cjs.map} +1 -1
  44. package/dist/{chunk-M7LIGEAX.cjs → chunk-VZGU4BV6.cjs} +33 -33
  45. package/dist/{chunk-M7LIGEAX.cjs.map → chunk-VZGU4BV6.cjs.map} +1 -1
  46. package/dist/{chunk-HGKWTKW7.js → chunk-WJRPMULP.js} +2 -2
  47. package/dist/chunk-WJRPMULP.js.map +1 -0
  48. package/dist/{chunk-NOWXUQWF.js → chunk-XXG2Z2JT.js} +3 -3
  49. package/dist/{chunk-3I4IZYHM.js → chunk-ZVUR3A33.js} +3 -3
  50. package/dist/components/announcement-bar.d.ts +27 -1
  51. package/dist/components/announcement-bar.d.ts.map +1 -1
  52. package/dist/components/case-studies/index.cjs +10 -10
  53. package/dist/components/case-studies/index.js +4 -4
  54. package/dist/components/chat/index.cjs +4 -4
  55. package/dist/components/chat/index.js +3 -3
  56. package/dist/components/contact/index.cjs +5 -5
  57. package/dist/components/contact/index.js +4 -4
  58. package/dist/components/docs/index.cjs +7 -7
  59. package/dist/components/docs/index.js +6 -6
  60. package/dist/components/embeds/index.cjs +5 -5
  61. package/dist/components/embeds/index.js +4 -4
  62. package/dist/components/faq/index.cjs +6 -6
  63. package/dist/components/faq/index.js +5 -5
  64. package/dist/components/features/index.cjs +4 -4
  65. package/dist/components/features/index.js +3 -3
  66. package/dist/components/help-center-pages/index.cjs +23 -23
  67. package/dist/components/help-center-pages/index.js +14 -14
  68. package/dist/components/index.cjs +274 -299
  69. package/dist/components/index.cjs.map +1 -1
  70. package/dist/components/index.js +110 -135
  71. package/dist/components/index.js.map +1 -1
  72. package/dist/components/navigation/index.cjs +4 -4
  73. package/dist/components/navigation/index.js +3 -3
  74. package/dist/components/onboarding-guides/index.cjs +8 -8
  75. package/dist/components/onboarding-guides/index.js +7 -7
  76. package/dist/components/related-content/index.cjs +6 -6
  77. package/dist/components/related-content/index.js +5 -5
  78. package/dist/components/tickets/index.cjs +7 -7
  79. package/dist/components/tickets/index.js +6 -6
  80. package/dist/components/ui/file-manager/index.cjs +50 -50
  81. package/dist/components/ui/file-manager/index.js +1 -1
  82. package/dist/components/ui/index.cjs +4 -4
  83. package/dist/components/ui/index.js +3 -3
  84. package/dist/hooks/api-hooks.d.ts +0 -4
  85. package/dist/hooks/api-hooks.d.ts.map +1 -1
  86. package/dist/hooks/index.cjs +2 -2
  87. package/dist/hooks/index.js +1 -1
  88. package/dist/hooks/use-self-fetch.d.ts +7 -0
  89. package/dist/hooks/use-self-fetch.d.ts.map +1 -1
  90. package/dist/index.cjs +20 -4
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +19 -3
  93. package/dist/types/announcement.cjs +5 -0
  94. package/dist/types/announcement.cjs.map +1 -1
  95. package/dist/types/announcement.d.ts +37 -7
  96. package/dist/types/announcement.d.ts.map +1 -1
  97. package/dist/types/announcement.js +5 -1
  98. package/dist/types/announcement.js.map +1 -1
  99. package/dist/types/index.cjs +5 -0
  100. package/dist/types/index.cjs.map +1 -1
  101. package/dist/types/index.js +5 -1
  102. package/dist/types/index.js.map +1 -1
  103. package/dist/utils/announcement-storage.d.ts +39 -7
  104. package/dist/utils/announcement-storage.d.ts.map +1 -1
  105. package/dist/utils/app-config.d.ts.map +1 -1
  106. package/dist/utils/color-analysis.d.ts +13 -0
  107. package/dist/utils/color-analysis.d.ts.map +1 -1
  108. package/dist/utils/index.cjs +58 -1
  109. package/dist/utils/index.cjs.map +1 -1
  110. package/dist/utils/index.d.ts +1 -0
  111. package/dist/utils/index.d.ts.map +1 -1
  112. package/dist/utils/index.js +52 -2
  113. package/dist/utils/index.js.map +1 -1
  114. package/package.json +1 -1
  115. package/src/components/.announcement-bar.md +32 -30
  116. package/src/components/announcement-bar.tsx +227 -182
  117. package/src/components/features/video.tsx +8 -0
  118. package/src/components/ui/button/button.tsx +1 -1
  119. package/src/hooks/.api-hooks.md +1 -4
  120. package/src/hooks/api-hooks.ts +0 -7
  121. package/src/hooks/use-self-fetch.ts +29 -1
  122. package/src/stories/AnnouncementBar.stories.tsx +188 -152
  123. package/src/types/.announcement.md +2 -2
  124. package/src/types/announcement.ts +37 -7
  125. package/src/utils/.announcement-storage.md +19 -24
  126. package/src/utils/announcement-storage.ts +74 -28
  127. package/src/utils/app-config.ts +10 -1
  128. package/src/utils/color-analysis.ts +16 -1
  129. package/src/utils/index.ts +3 -0
  130. package/dist/chunk-DH6HTYOV.js.map +0 -1
  131. package/dist/chunk-G3QYCV63.cjs.map +0 -1
  132. package/dist/chunk-GA5AN24T.cjs.map +0 -1
  133. package/dist/chunk-HGKWTKW7.js.map +0 -1
  134. package/dist/chunk-J4XR6YYZ.cjs.map +0 -1
  135. package/dist/chunk-MTBFE3JF.js.map +0 -1
  136. package/dist/chunk-Q3AO2QWK.cjs.map +0 -1
  137. package/dist/chunk-TTUQSOBL.js.map +0 -1
  138. /package/dist/{chunk-HBO2DMX4.js.map → chunk-3KZXOLFK.js.map} +0 -0
  139. /package/dist/{chunk-RNKXG55J.js.map → chunk-6WMVBCNA.js.map} +0 -0
  140. /package/dist/{chunk-A636Y7GU.js.map → chunk-7RVSGGO6.js.map} +0 -0
  141. /package/dist/{chunk-YXOWUHGQ.js.map → chunk-AYFWYOL6.js.map} +0 -0
  142. /package/dist/{chunk-GYFUGMNM.js.map → chunk-C3NPMZBI.js.map} +0 -0
  143. /package/dist/{chunk-YORONSXG.js.map → chunk-LSPBPUQM.js.map} +0 -0
  144. /package/dist/{chunk-AMPQKNAX.js.map → chunk-OATWTTCI.js.map} +0 -0
  145. /package/dist/{chunk-R6SMQ2CO.js.map → chunk-PQN5R5II.js.map} +0 -0
  146. /package/dist/{chunk-5Z5GXXRU.js.map → chunk-TFDNJTBX.js.map} +0 -0
  147. /package/dist/{chunk-NOWXUQWF.js.map → chunk-XXG2Z2JT.js.map} +0 -0
  148. /package/dist/{chunk-3I4IZYHM.js.map → chunk-ZVUR3A33.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.404",
3
+ "version": "0.0.405-snapshot.20260711081601",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,40 +1,42 @@
1
- <!-- source-hash: cbe120bea34edef14f05fff077b0b285 -->
2
- A client-side React component that renders a dismissible announcement banner, fetching active announcements from a configured API endpoint with localStorage-based caching and per-announcement dismissal persistence.
1
+ <!-- source-hash: pending-regeneration -->
2
+ A dismissible platform announcement bar with DUAL MODE support: SSR-seeded (Next hosts) and client-only (bare React apps without SSR). Single-line 44px strip, contrast handled by scoping content with the ODS theme classes, event-driven freshness (no polling).
3
3
 
4
4
  ## Key Components
5
5
 
6
6
  | Export/Function | Description |
7
7
  |----------------|-------------|
8
- | `AnnouncementBar` | Main component — renders the announcement banner or `null` if hidden/dismissed |
9
- | `fetchActiveAnnouncement` | Fetches the active announcement from `announcementsUrl`, updates state and localStorage cache |
10
- | `handleDismiss` | Marks the current announcement as dismissed in localStorage and hides the bar |
11
- | `handleCtaClick` | Navigates to the CTA URL, respecting `_blank` target for new-tab behavior |
12
- | `renderIcon` | Renders either a PNG `<Image>` or an SVG icon based on `announcement.icon_type` |
13
- | `getSvgIcon` | Thin wrapper around `renderSvgIcon` applying size-appropriate Tailwind classes |
8
+ | `AnnouncementBar` | Main component — renders the bar or `null` when there is nothing to show |
9
+ | `initialAnnouncement` prop | SSR mode seed (nullable): server resolved announcement + dismissal cookie; skips the mount fetch |
10
+ | `previewMode` prop | Render-only (admin live preview): no fetch, inert dismiss, no storage side effects |
11
+
12
+ ## Modes
13
+
14
+ - **SSR mode** (hub / any Next host): the server passes `initialAnnouncement` (already dismissal-filtered via the cookie helpers in `announcement-storage.ts`). The bar is in the first HTML byte — zero layout shift, no flash for dismissed users, zero mount fetch.
15
+ - **Client-only mode** (Vite/CRA embeds, no SSR — the react-embedding-example structure): mount the bar PROP-LESS inside an EndpointsRuntime provider; `announcementsUrl` is a fixed suffix under the embedder's one content base (`${CONTENT}/announcements/active`). No provider → renders nothing, never fetches. Appearance animates in (grid `0fr -> 1fr`). Deliberately NO per-component URL or platform knobs: the platform is never sent as a parameter — each server returns its own announcement via `currentPlatform()`.
14
16
 
15
17
  ## Behavior Notes
16
18
 
17
- - **Instant paint**: On mount, the cached announcement from localStorage is applied synchronously before the API response arrives.
18
- - **Polling**: Refreshes every 5 minutes via `setInterval`; interval restarts if `announcementsUrl` changes.
19
- - **No provider fallback**: When `useEndpointsRuntime` returns no URL, polling is skipped entirely cached data still renders.
20
- - **Platform-scoped keys**: localStorage keys are prefixed with the platform type (via `getAppType()`) to prevent key collisions across apps.
21
- - **Responsive CTA**: The CTA button is desktop-only; on mobile, tapping the content area triggers `handleCtaClick` directly.
22
-
23
- ## Usage Example
24
-
25
- ```typescript
26
- // Wrap your app with the endpoints provider, then drop in the bar:
27
- import { AnnouncementBar } from './announcement-bar';
28
- import { EndpointsRuntimeProvider } from '../contexts/endpoints-runtime-context';
29
-
30
- function AppShell() {
31
- return (
32
- <EndpointsRuntimeProvider announcementsUrl="/api/announcements/active">
33
- <AnnouncementBar />
34
- <main>{/* app content */}</main>
35
- </EndpointsRuntimeProvider>
36
- );
19
+ - **No polling**: freshness is visibility-driven `useSelfFetch`'s `revalidateOnVisibleAfterMs` refetches on tab refocus when held data is older than 60s. Idle tabs make zero requests.
20
+ - **Dismissal**: cookie is the SSOT (`<platform>-announcement-dismissed=<id>`, id-match); localStorage is read-only legacy. Storage reads happen only in effects (hydration safety).
21
+ - **Contrast**: the admin background's tone selects `.theme-light`/`.theme-dark` around the content; all colors resolve from the active ODS theme's Tier-1 primitives.
22
+ - **Responsive**: single truncating line (description hidden < sm); CTA is desktop-only, the whole bar is the tap target on mobile.
23
+
24
+ ## Usage Examples
25
+
26
+ ```tsx
27
+ // SSR mode (Next server component resolves the seed):
28
+ <AnnouncementBar initialAnnouncement={announcementOrNull} />
29
+
30
+ // Client-only mode (react-embedding-example structure): one content base,
31
+ // endpoints built once, bar mounted prop-less inside the provider.
32
+ const endpointsRuntime: EndpointsRuntime = {
33
+ announcementsUrl: `${CONTENT}/announcements/active`,
34
+ accessCode: { validateUrl: `${CONTENT}/validate-access-code`, consumeUrl: `${CONTENT}/consume-access-code` },
35
+ contactUrl: `${CONTENT}/contact`,
37
36
  }
38
- ```
39
37
 
40
- > **Note:** The component renders nothing (`null`) when no active announcement exists, when the fetch URL is unconfigured, or when the user has previously dismissed the current announcement.
38
+ <EndpointsRuntimeContext.Provider value={endpointsRuntime}>
39
+ <AnnouncementBar />
40
+ <main>{/* app content */}</main>
41
+ </EndpointsRuntimeContext.Provider>
42
+ ```
@@ -1,221 +1,266 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useEffect, useMemo, useState } from 'react';
4
4
  import { X } from 'lucide-react';
5
5
  import { Button } from './ui/button';
6
6
  import { EntityIcon } from './icon-display';
7
7
  import {
8
- setStoredAnnouncement,
9
- getStoredAnnouncement,
10
- clearStoredAnnouncement,
8
+ dismissAnnouncement,
9
+ isAnnouncementDismissed,
10
+ clearLegacyAnnouncementCache,
11
11
  } from '../utils/announcement-storage';
12
- import { Announcement } from '../types/announcement';
12
+ import type { AnnouncementBarProps, AnnouncementResponse } from '../types/announcement';
13
13
  import { getAppType } from '../utils/app-config';
14
14
  import { useEndpointsRuntime } from '../contexts/endpoints-runtime-context';
15
+ import { useSelfFetch } from '../hooks/use-self-fetch';
16
+ import { pickReadableTextColor } from '../utils/color-analysis';
15
17
 
16
- export function AnnouncementBar() {
17
- const [announcement, setAnnouncement] = useState<Announcement | null>(null);
18
- const [isVisible, setIsVisible] = useState<boolean>(false);
19
-
20
- // Get the platform type for platform-specific localStorage keys
18
+ /**
19
+ * Platform announcement bar — DUAL MODE, works with or without SSR.
20
+ *
21
+ * SSR mode (Next hosts, e.g. the hub): the server resolves the announcement
22
+ * and the dismissal cookie, then passes `initialAnnouncement` (nullable).
23
+ * The bar renders in the first HTML byte (zero layout shift, no flash for
24
+ * dismissed users) and skips the mount fetch.
25
+ *
26
+ * Client-only mode (bare React apps without SSR: Vite/CRA embeds — see
27
+ * react-embedding-example): omit `initialAnnouncement` and mount the bar
28
+ * PROP-LESS inside an EndpointsRuntime provider; it self-fetches from the
29
+ * provider's `announcementsUrl` (a fixed suffix under the embedder's one
30
+ * content base). No provider → renders nothing, never fetches (silent
31
+ * no-op). Appearance animates in (grid 0fr→1fr) so there is no hard layout
32
+ * jump. Deliberately NO per-component URL or platform knobs: the embedder
33
+ * is platform-agnostic — each server returns ITS OWN announcement via
34
+ * currentPlatform(); the platform is never sent as a parameter.
35
+ *
36
+ * Both modes: freshness is event-driven, no polling — `useSelfFetch`'s
37
+ * visibility revalidation re-fetches on tab refocus when the held data is
38
+ * >60s old (matching the server cache TTL by convention); idle tabs make
39
+ * zero requests. Dismissal cookie is the SSOT (`announcement-storage.ts`);
40
+ * storage reads happen ONLY in effects (a render-time read would desync
41
+ * hydration in SSR mode).
42
+ */
43
+ export function AnnouncementBar({
44
+ initialAnnouncement,
45
+ previewMode = false,
46
+ className,
47
+ }: AnnouncementBarProps = {}) {
48
+ // Namespace for the dismissal cookie/legacy keys. Next hosts inline
49
+ // NEXT_PUBLIC_APP_TYPE (matching the server's currentPlatform(), which the
50
+ // hub layout uses for the SSR cookie read); platform-agnostic embeds get
51
+ // the fallback — fine, cookies are domain-scoped and an embed domain
52
+ // serves one platform's announcements.
21
53
  const platform = getAppType();
22
54
 
23
- // Optional endpoint runtime: when no provider is mounted (e.g. on a
24
- // bare React-tree page that doesn't wrap with HubRuntimeProvider),
25
- // the bar silently skips its fetch instead of throwing. Apps that DO
26
- // mount the provider get the configured URL — typically
27
- // '/api/announcements/active' in the hub, or a proxied path in an
28
- // embedded host.
55
+ // Optional endpoint runtime: no provider no URL fetching disabled.
29
56
  const endpoints = useEndpointsRuntime();
30
- const announcementsUrl = endpoints?.announcementsUrl;
31
-
32
- // Helper to determine dismissal key for localStorage
33
- const getDismissKey = (id: string) => `${platform}-announcement-${id}-dismissed`;
34
-
35
- // Helper to get platform-specific cache key
36
- const getCacheKey = () => `${platform}-announcement-cache`;
37
-
38
- // Fetch active announcement from API and update state + LS
39
- const fetchActiveAnnouncement = async () => {
40
- // No provider mounted → no URL configured → skip fetch silently.
41
- // Cached announcement from previous sessions still renders if present.
42
- if (!announcementsUrl) return;
43
- try {
44
- // Server-side platform injection - no URL parameter needed
45
- const response = await fetch(announcementsUrl);
46
-
47
- if (response.ok) {
48
- const data = await response.json();
49
- if (data.announcement) {
50
- setAnnouncement(data.announcement);
51
-
52
- // persist latest announcement for quick future loads with platform-specific key
53
- setStoredAnnouncement(getCacheKey(), data.announcement);
54
-
55
- // Check if this specific announcement was dismissed
56
- const isDismissed = localStorage.getItem(getDismissKey(data.announcement.id));
57
- setIsVisible(!isDismissed);
58
- } else {
59
- // No announcement available - clean up localStorage and hide bar
60
- setAnnouncement(null);
61
- setIsVisible(false);
62
-
63
- // Use utility function to properly clear platform-specific announcement data
64
- clearStoredAnnouncement(getCacheKey());
65
- }
66
- } else {
67
- // Network or other error - hide announcement and clean up
68
- console.error(`❌ [${platform.toUpperCase()}] Error fetching announcement: ${response.status}`);
69
- setAnnouncement(null);
70
- setIsVisible(false);
71
-
72
- // Clear stale data on network errors too
73
- clearStoredAnnouncement(getCacheKey());
74
- }
75
- } catch (error) {
76
- console.error('Error fetching active announcement:', error);
77
- setAnnouncement(null);
78
- setIsVisible(false);
79
-
80
- // Clear stale data on exceptions too
81
- clearStoredAnnouncement(getCacheKey());
82
- }
83
- };
57
+ const url = previewMode ? null : endpoints?.announcementsUrl ?? null;
84
58
 
85
- // Initial load: use cached announcement synchronously for instant paint
86
- useEffect(() => {
87
- const cached = getStoredAnnouncement(getCacheKey());
88
- if (cached) {
89
- const isDismissed = localStorage.getItem(getDismissKey(cached.id));
90
- setAnnouncement(cached);
91
- setIsVisible(!isDismissed);
92
- }
59
+ // MUST be memoized (the hook re-syncs on [initialData] identity — an inline
60
+ // literal per render would setData-loop) and strict-undefined-mapped:
61
+ // `null` (hub: dismissed / no active announcement) still seeds the hook and
62
+ // skips the mount fetch; only an ABSENT prop (embeds) enables self-fetch.
63
+ const initialData = useMemo<AnnouncementResponse | undefined>(
64
+ () => (initialAnnouncement === undefined ? undefined : { announcement: initialAnnouncement }),
65
+ [initialAnnouncement],
66
+ );
93
67
 
94
- // No provider mounted no URL → no fetch / no polling. Cached
95
- // announcement still painted above. Skip scheduling the 5-min
96
- // interval entirely to avoid an idle timer + repeated short-circuit
97
- // calls.
98
- if (!announcementsUrl) return;
68
+ const { data } = useSelfFetch<AnnouncementResponse>(url, {
69
+ initialData,
70
+ revalidateOnVisibleAfterMs: 60_000,
71
+ });
72
+ const announcement = data?.announcement ?? null;
99
73
 
100
- // Always fetch latest on mount
101
- fetchActiveAnnouncement();
74
+ // Expanded (height) state initial value is a pure function of props so
75
+ // the SSR HTML and the hydration render agree for every cohort.
76
+ const [expandedState, setExpandedState] = useState<boolean>(() => initialAnnouncement != null);
77
+ // Preview always mirrors the draft directly (effects are disabled there).
78
+ const expanded = previewMode ? announcement != null : expandedState;
102
79
 
103
- // Schedule refresh every 5 minutes. When announcementsUrl flips
104
- // (e.g. provider value swap), the effect re-runs and restarts the
105
- // interval against the new URL — no stale captured fetch.
106
- const interval = setInterval(fetchActiveAnnouncement, 300_000);
107
- return () => clearInterval(interval);
80
+ // One-time cleanup of the pre-refactor localStorage announcement cache.
81
+ useEffect(() => {
82
+ if (previewMode) return;
83
+ clearLegacyAnnouncementCache(platform);
108
84
  // eslint-disable-next-line react-hooks/exhaustive-deps
109
- }, [announcementsUrl]);
85
+ }, []);
86
+
87
+ // Visibility reconciliation — runs on the seed and after EVERY completed
88
+ // fetch (keyed on `data` identity, not announcement id, so a refocus
89
+ // revalidation that returns the same announcement still re-checks the
90
+ // dismissal store: this is what keeps a bar dismissed seconds earlier in
91
+ // another tab from resurrecting). Also the legacy-migration point: an
92
+ // LS-only dismissal (no cookie — server couldn't see it) collapses once,
93
+ // animated, and backfills the cookie so the next SSR skips the bar.
94
+ useEffect(() => {
95
+ if (previewMode) return;
96
+ if (!announcement) {
97
+ setExpandedState(false);
98
+ return;
99
+ }
100
+ if (isAnnouncementDismissed(platform, announcement.id)) {
101
+ dismissAnnouncement(platform, announcement.id); // idempotent cookie backfill
102
+ setExpandedState(false);
103
+ } else {
104
+ setExpandedState(true);
105
+ }
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [data]);
110
108
 
111
- // helpers
112
109
  const handleDismiss = () => {
113
- if (!announcement) return;
114
- localStorage.setItem(getDismissKey(announcement.id), 'true');
115
- setIsVisible(false);
110
+ if (previewMode || !announcement) return;
111
+ dismissAnnouncement(platform, announcement.id);
112
+ setExpandedState(false);
116
113
  };
117
114
 
118
115
  const handleCtaClick = () => {
119
- if (!announcement?.cta_url) return;
116
+ if (previewMode || !announcement?.cta_url) return;
117
+ // Scheme guard: cta_url is admin-entered data — only http(s) (absolute or
118
+ // relative) may navigate. A `javascript:` value would otherwise execute
119
+ // via location.href / window.open (stored XSS).
120
+ let safeUrl: string;
121
+ try {
122
+ const parsed = new URL(announcement.cta_url, window.location.origin);
123
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
124
+ safeUrl = parsed.href;
125
+ } catch {
126
+ return;
127
+ }
120
128
  announcement.cta_target === '_blank'
121
- ? window.open(announcement.cta_url, '_blank', 'noopener,noreferrer')
122
- : (window.location.href = announcement.cta_url);
129
+ ? window.open(safeUrl, '_blank', 'noopener,noreferrer')
130
+ : (window.location.href = safeUrl);
123
131
  };
124
132
 
125
- const renderIcon = () => {
126
- if (!announcement) return null;
127
- // ONE unified display path (shared with the chat): uploaded image URL wins,
128
- // else a library glyph by name (+ props), via <EntityIcon>.
129
- return (
130
- <EntityIcon
131
- icon={{
132
- name: announcement.icon_name || 'openframe-logo',
133
- url: announcement.icon_url,
134
- props: announcement.icon_props,
135
- }}
136
- size={32}
137
- className="relative shrink-0 w-6 h-6 md:w-8 md:h-8"
138
- />
139
- );
140
- };
133
+ // Nothing to show (and nothing to animate around): render nothing. The
134
+ // dismissed/deactivated case keeps the element mounted at 0fr so the
135
+ // collapse animates.
136
+ if (!announcement) return null;
137
+
138
+ // Contrast is solved with the ODS THEME system, not color literals: the
139
+ // admin background's tone selects which ODS theme scopes the bar's content
140
+ // (light-toned background needs dark-on-light = the light theme's values;
141
+ // dark-toned needs the dark theme's). `.theme-light` / `.theme-dark` are
142
+ // the design system's own scoping classes (ods-colors.css) and flip every
143
+ // Tier-1 `--ods-*` primitive for descendants.
144
+ const themeScope = pickReadableTextColor(announcement.background_color) === 'dark' ? 'theme-light' : 'theme-dark';
145
+
146
+ // Inside the scope, colors reference Tier-1 theme primitives directly
147
+ // (Tier-2 `--color-*` aliases resolve once at :root and do NOT re-resolve
148
+ // in a nested theme scope — verified). `--ods-system-greys-white` is the
149
+ // theme's primary-text primitive; `--ods-system-greys-black-hover/-action`
150
+ // are its quiet hover/active surfaces. Every value comes from the active
151
+ // ODS theme; nothing is hardcoded.
152
+ const barButtonClasses =
153
+ 'text-[color:var(--ods-system-greys-white)] hover:bg-[var(--ods-system-greys-black-hover)] active:bg-[var(--ods-system-greys-black-action)]';
141
154
 
142
- // If no announcement or dismissed => render nothing
143
- if (!announcement || !isVisible) return null;
155
+ const hasCta = Boolean(announcement.cta_enabled && announcement.cta_url);
144
156
 
145
157
  return (
146
158
  <div
147
- className="relative w-full z-50"
148
- style={{ backgroundColor: announcement.background_color }}
159
+ role="region"
160
+ aria-label="Announcement"
161
+ aria-hidden={!expanded}
149
162
  data-announcement-bar
163
+ className={`relative w-full z-50 grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none ${className ?? ''}`}
164
+ style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
150
165
  >
151
- <div className="flex items-center w-full max-w-full">
152
- {/* Mobile: Clickable content area, Desktop: Regular content */}
153
- <div
154
- className={`flex flex-row gap-2 md:gap-4 items-center pl-4 md:pl-6 py-1.5 md:py-2 flex-1 min-w-0 ${
155
- announcement.cta_enabled && announcement.cta_url ? 'md:cursor-default cursor-pointer' : ''
156
- }`}
157
- onClick={(e) => {
158
- // Only handle click on mobile (< 768px) and if CTA is enabled
159
- if (window.innerWidth < 768 && announcement.cta_enabled && announcement.cta_url) {
160
- e.preventDefault();
161
- handleCtaClick();
162
- }
163
- }}
164
- >
165
- {renderIcon()}
166
-
167
- <div className="flex-1 min-w-0 max-w-full">
168
- <p className="font-body font-bold text-[14px] md:text-[18px] leading-tight tracking-tight mb-0 text-[#1A1A1A] truncate">
169
- {announcement.title}
170
- </p>
171
- <p className="font-body text-[12px] md:text-[18px] leading-tight hidden md:block text-[#1A1A1A] truncate">
172
- {announcement.description}
166
+ {/*
167
+ Bar anatomy follows the announcement-bar industry standard: ONE line of
168
+ text at 13-14px inside a 44px-tall strip (guides converge on 40-60px
169
+ with a single sentence; two stacked 18px rows blew past that), title +
170
+ description merged inline (description hidden on small screens), ONE
171
+ compact CTA on the right, and a ghost-icon dismiss (design-system
172
+ icon-sm: 32px target, >= the 24px WCAG 2.2 SC 2.5.8 AA floor).
173
+ */}
174
+ <div className={`min-h-0 overflow-hidden ${themeScope}`} style={{ backgroundColor: announcement.background_color }}>
175
+ <div className="flex items-center w-full max-w-full min-h-11 text-[color:var(--ods-system-greys-white)]">
176
+ {/* Mobile: Clickable content area, Desktop: Regular content */}
177
+ <div
178
+ className={`flex flex-row gap-2 md:gap-3 items-center pl-4 md:pl-6 py-1.5 flex-1 min-w-0 ${
179
+ hasCta ? 'md:cursor-default cursor-pointer' : ''
180
+ }`}
181
+ onClick={(e) => {
182
+ // Only handle click on mobile (< 768px) and if CTA is enabled
183
+ if (window.innerWidth < 768 && hasCta) {
184
+ e.preventDefault();
185
+ handleCtaClick();
186
+ }
187
+ }}
188
+ >
189
+ {/* ONE unified icon path (shared with the chat): uploaded image URL
190
+ wins, else a library glyph by name (+ props), via <EntityIcon>. */}
191
+ <EntityIcon
192
+ icon={{
193
+ name: announcement.icon_name || 'openframe-logo',
194
+ url: announcement.icon_url,
195
+ props: announcement.icon_props,
196
+ }}
197
+ size={24}
198
+ className="relative shrink-0 w-5 h-5 md:w-6 md:h-6"
199
+ />
200
+
201
+ {/* Single-line message: bold title + regular description inline,
202
+ truncating as one unit. Separator is a middot (house rule: no
203
+ en/em dashes in copy). */}
204
+ <p className="font-body flex-1 min-w-0 max-w-full text-[13px] md:text-sm leading-snug truncate mb-0">
205
+ <span className="font-semibold">{announcement.title}</span>
206
+ {announcement.description && (
207
+ <span className="hidden sm:inline opacity-80"> · {announcement.description}</span>
208
+ )}
173
209
  </p>
210
+
211
+ {/* CTA - the common Button in the bar's quiet treatment: ghost
212
+ surface, computed-foreground text, translucent fg-tint hover
213
+ (barButtonClasses) so nothing renders as a dark slab on the
214
+ admin color. The admin cta_button_* colors are NOT applied:
215
+ they were designed for the legacy bespoke treatment. Hidden
216
+ on mobile, where the whole bar is the tap target. */}
217
+ {hasCta && announcement.cta_text && (
218
+ <div className="hidden md:flex flex-shrink-0 ml-1">
219
+ <Button
220
+ onClick={handleCtaClick}
221
+ variant="transparent"
222
+ size="small"
223
+ className={barButtonClasses}
224
+ tabIndex={expanded ? 0 : -1}
225
+ leftIcon={
226
+ announcement.cta_show_icon && announcement.cta_icon_name
227
+ ? (
228
+ <EntityIcon
229
+ icon={{ name: announcement.cta_icon_name, props: announcement.cta_icon_props }}
230
+ size={14}
231
+ className="w-3.5 h-3.5"
232
+ />
233
+ )
234
+ : undefined
235
+ }
236
+ >
237
+ {announcement.cta_text}
238
+ </Button>
239
+ </div>
240
+ )}
174
241
  </div>
175
242
 
176
- {/* CTA Button - Hidden on mobile, shown on desktop */}
177
- {announcement.cta_enabled && announcement.cta_text && announcement.cta_url && (
178
- <div className="hidden md:flex flex-shrink-0 ml-1 md:ml-2">
179
- <Button
180
- onClick={handleCtaClick}
181
- variant="outline"
182
- size="small-legacy"
183
- leftIcon={
184
- announcement.cta_show_icon && announcement.cta_icon_name
185
- ? (
186
- <EntityIcon
187
- icon={{ name: announcement.cta_icon_name, props: announcement.cta_icon_props }}
188
- size={16}
189
- className="w-3 h-3 md:w-4 md:h-4"
190
- />
191
- )
192
- : undefined
193
- }
194
- className="transition-opacity hover:opacity-90 text-xs md:text-sm whitespace-nowrap"
195
- style={{
196
- backgroundColor: announcement.cta_button_background_color || undefined,
197
- color: announcement.cta_button_text_color || undefined,
198
- borderColor: announcement.cta_button_background_color || undefined,
199
- }}
200
- >
201
- {announcement.cta_text}
202
- </Button>
203
- </div>
204
- )}
243
+ {/* Dismiss - the common Button in its ghost-icon treatment
244
+ (size="icon-sm": 32px target, >= the 24px WCAG 2.5.8 AA floor,
245
+ 16px glyph) with the bar's quiet tint hover. Inert in
246
+ previewMode. */}
247
+ <div className="flex-shrink-0 mr-1 md:mr-3">
248
+ <Button
249
+ onClick={(e) => {
250
+ e.stopPropagation(); // Prevent triggering the mobile CTA click
251
+ handleDismiss();
252
+ }}
253
+ variant="transparent"
254
+ size="icon-sm"
255
+ className={barButtonClasses}
256
+ aria-label="Dismiss announcement"
257
+ type="button"
258
+ tabIndex={expanded ? 0 : -1}
259
+ >
260
+ <X strokeWidth={2} />
261
+ </Button>
262
+ </div>
205
263
  </div>
206
-
207
- {/* Dismiss button - always visible */}
208
- <button
209
- onClick={(e) => {
210
- e.stopPropagation(); // Prevent triggering the mobile CTA click
211
- handleDismiss();
212
- }}
213
- className="flex-shrink-0 w-8 h-8 md:w-10 md:h-10 flex items-center justify-center hover:bg-[#1A1A1A]/10 focus:outline-none focus:ring-2 focus:ring-[#1A1A1A] mr-2 md:mr-4"
214
- aria-label="Dismiss announcement"
215
- type="button"
216
- >
217
- <X className="w-4 h-4 text-[#1A1A1A]" strokeWidth={2} />
218
- </button>
219
264
  </div>
220
265
  </div>
221
266
  );
@@ -892,6 +892,14 @@ function YouTubeFacadeInner({
892
892
  src={embedUrl}
893
893
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
894
894
  allowFullScreen
895
+ // YouTube verifies the embedding domain from the Referer header. When the
896
+ // host page runs under a strict `Referrer-Policy: no-referrer` (common on
897
+ // deployed environments behind a gateway/CDN), the iframe would otherwise
898
+ // inherit it, send no referrer, and YouTube rejects playback with
899
+ // "Error 153 — Video player configuration error". Pin the policy to the
900
+ // value YouTube's own recommended embed code uses so the origin is always
901
+ // sent and embedding is authorized.
902
+ referrerPolicy="strict-origin-when-cross-origin"
895
903
  title={title}
896
904
  className="absolute inset-0 w-full h-full border-0 rounded-lg"
897
905
  />
@@ -29,7 +29,7 @@ const buttonVariants = cva(
29
29
  size: {
30
30
  default: "py-[var(--spacing-system-sf)] px-[var(--spacing-system-m)] text-h3 md:h-12 h-10",
31
31
  small: "p-[var(--spacing-system-xs)] text-h5 h-6 md:h-8",
32
- "small-legacy": "py-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] h-10 text-[14px] font-bold", // Temporary alias for "small" to avoid breaking changes in AnnouncementBar's CTA button; will be removed in the future
32
+ "small-legacy": "py-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] h-10 text-[14px] font-bold", // Temporary alias for "small" deprecated; grep size="small-legacy" (lib + hub) and migrate the remaining consumers before removal
33
33
  icon: "p-[var(--spacing-system-sf)] h-11 w-11 md:h-12 md:w-12 [&_svg]:h-4 [&_svg]:w-4 md:[&_svg]:h-6 md:[&_svg]:w-6",
34
34
  // Quiet 32px icon target with a 16px glyph, fixed across breakpoints
35
35
  // (Carbon ghost sm / Primer medium / shadcn icon-sm all pin 32px;
@@ -5,18 +5,16 @@ This file provides stub React hooks for API data fetching, offering consistent i
5
5
 
6
6
  - **`useCategories()`** - Hook for fetching and managing category data with loading/error states
7
7
  - **`useVendors()`** - Hook for fetching and managing vendor data with loading/error states
8
- - **`useAnnouncements()`** - Hook for fetching announcement data with loading state
9
8
  - **`useSubcategoryCountByCategory()`** - Hook that returns empty subcategory count data
10
9
 
11
10
  ## Usage Example
12
11
 
13
12
  ```typescript
14
- import { useCategories, useVendors, useAnnouncements } from './api-hooks';
13
+ import { useCategories, useVendors } from './api-hooks';
15
14
 
16
15
  function MyComponent() {
17
16
  const { categories, loading: categoriesLoading, error: categoriesError } = useCategories();
18
17
  const { vendors, loading: vendorsLoading, error: vendorsError } = useVendors();
19
- const { announcements, loading: announcementsLoading } = useAnnouncements();
20
18
 
21
19
  if (categoriesLoading || vendorsLoading) {
22
20
  return <div>Loading...</div>;
@@ -30,7 +28,6 @@ function MyComponent() {
30
28
  <div>
31
29
  <h2>Categories: {categories.length}</h2>
32
30
  <h2>Vendors: {vendors.length}</h2>
33
- <h2>Announcements: {announcements.length}</h2>
34
31
  </div>
35
32
  );
36
33
  }
@@ -17,13 +17,6 @@ export function useVendors() {
17
17
  return { vendors, loading, error };
18
18
  }
19
19
 
20
- export function useAnnouncements() {
21
- const [announcements, setAnnouncements] = useState([]);
22
- const [loading, setLoading] = useState(false);
23
-
24
- return { announcements, loading };
25
- }
26
-
27
20
  export function useSubcategoryCountByCategory() {
28
21
  return { data: {}, loading: false };
29
22
  }