@flamingo-stack/openframe-frontend-core 0.0.582-1967.5390.1 → 0.0.582

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 (115) hide show
  1. package/dist/chunk-2EIGO6ZS.js +1055 -0
  2. package/dist/chunk-2EIGO6ZS.js.map +1 -0
  3. package/dist/{chunk-PDK6KVU6.js → chunk-53ZVUW2M.js} +4 -4
  4. package/dist/{chunk-BTXJQPGE.js → chunk-5U2FU5LM.js} +2 -2
  5. package/dist/{chunk-VG2SRECA.cjs → chunk-7WE6FGGK.cjs} +49 -49
  6. package/dist/{chunk-VG2SRECA.cjs.map → chunk-7WE6FGGK.cjs.map} +1 -1
  7. package/dist/{chunk-EXBNO6FB.js → chunk-CJW6KZD3.js} +2 -2
  8. package/dist/{chunk-UQXQCM3F.cjs → chunk-E4TK2BBD.cjs} +3 -3
  9. package/dist/{chunk-UQXQCM3F.cjs.map → chunk-E4TK2BBD.cjs.map} +1 -1
  10. package/dist/{chunk-NOO3QOJI.js → chunk-EULY3BXB.js} +2 -2
  11. package/dist/{chunk-ML22BIKH.js → chunk-GAL6EXUY.js} +3 -3
  12. package/dist/{chunk-YTZK2ORN.js → chunk-H7K76TPX.js} +4 -4
  13. package/dist/{chunk-RH7EY7YN.cjs → chunk-HMEEW73F.cjs} +1978 -237
  14. package/dist/chunk-HMEEW73F.cjs.map +1 -0
  15. package/dist/{chunk-KNQZZJYJ.js → chunk-IB23CRL2.js} +2 -2
  16. package/dist/{chunk-GHYWIVSU.cjs → chunk-IVJ6TBVW.cjs} +34 -34
  17. package/dist/{chunk-GHYWIVSU.cjs.map → chunk-IVJ6TBVW.cjs.map} +1 -1
  18. package/dist/{chunk-XJ3Y4OOF.js → chunk-IZGXIZWF.js} +2 -2
  19. package/dist/chunk-JMWLF2GL.cjs +1055 -0
  20. package/dist/chunk-JMWLF2GL.cjs.map +1 -0
  21. package/dist/{chunk-MCFETAH7.js → chunk-JUXCEHK2.js} +2 -2
  22. package/dist/{chunk-KX4F4O26.cjs → chunk-KS7NKONC.cjs} +3 -3
  23. package/dist/{chunk-KX4F4O26.cjs.map → chunk-KS7NKONC.cjs.map} +1 -1
  24. package/dist/{chunk-VYNYJVSE.cjs → chunk-LHQ6RVFE.cjs} +17 -17
  25. package/dist/{chunk-VYNYJVSE.cjs.map → chunk-LHQ6RVFE.cjs.map} +1 -1
  26. package/dist/{chunk-LGHMGCUG.cjs → chunk-M3ERSHPM.cjs} +4 -4
  27. package/dist/{chunk-LGHMGCUG.cjs.map → chunk-M3ERSHPM.cjs.map} +1 -1
  28. package/dist/{chunk-Q7X74UZF.cjs → chunk-RBMVTCRM.cjs} +3 -3
  29. package/dist/{chunk-Q7X74UZF.cjs.map → chunk-RBMVTCRM.cjs.map} +1 -1
  30. package/dist/{chunk-LYU3IZOA.cjs → chunk-RILGQ2OY.cjs} +10 -10
  31. package/dist/{chunk-LYU3IZOA.cjs.map → chunk-RILGQ2OY.cjs.map} +1 -1
  32. package/dist/{chunk-T6DRWYLM.cjs → chunk-RQMS2CF5.cjs} +17 -17
  33. package/dist/{chunk-T6DRWYLM.cjs.map → chunk-RQMS2CF5.cjs.map} +1 -1
  34. package/dist/{chunk-TC47NAJ3.cjs → chunk-S2ZXUQLR.cjs} +16 -16
  35. package/dist/{chunk-TC47NAJ3.cjs.map → chunk-S2ZXUQLR.cjs.map} +1 -1
  36. package/dist/{chunk-G3ZMLSKJ.js → chunk-SM6JAZ4C.js} +2 -2
  37. package/dist/{chunk-LENIASJW.js → chunk-T4DIAKIK.js} +2 -2
  38. package/dist/{chunk-ODOFQLSP.cjs → chunk-TMNFC24G.cjs} +13 -13
  39. package/dist/{chunk-ODOFQLSP.cjs.map → chunk-TMNFC24G.cjs.map} +1 -1
  40. package/dist/{chunk-FVF2MVIT.js → chunk-U6GFESVF.js} +2 -2
  41. package/dist/{chunk-2T7GXHRQ.js → chunk-XA5UPYWE.js} +2745 -1004
  42. package/dist/chunk-XA5UPYWE.js.map +1 -0
  43. package/dist/{chunk-TVLQVTZ6.cjs → chunk-XNSEZVV4.cjs} +4 -4
  44. package/dist/{chunk-TVLQVTZ6.cjs.map → chunk-XNSEZVV4.cjs.map} +1 -1
  45. package/dist/{chunk-XJJ2D64Q.cjs → chunk-YU4QZ6I6.cjs} +3 -3
  46. package/dist/{chunk-XJJ2D64Q.cjs.map → chunk-YU4QZ6I6.cjs.map} +1 -1
  47. package/dist/{chunk-LFG5JG2D.js → chunk-ZSENCE55.js} +5 -5
  48. package/dist/components/case-studies/index.cjs +8 -8
  49. package/dist/components/case-studies/index.js +2 -2
  50. package/dist/components/contact/index.cjs +3 -3
  51. package/dist/components/contact/index.js +2 -2
  52. package/dist/components/docs/index.cjs +6 -6
  53. package/dist/components/docs/index.js +5 -5
  54. package/dist/components/embeds/index.cjs +3 -3
  55. package/dist/components/embeds/index.js +2 -2
  56. package/dist/components/faq/index.cjs +3 -3
  57. package/dist/components/faq/index.js +2 -2
  58. package/dist/components/features/index.cjs +2 -2
  59. package/dist/components/features/index.js +1 -1
  60. package/dist/components/features/push-button-selector.d.ts +29 -1
  61. package/dist/components/features/push-button-selector.d.ts.map +1 -1
  62. package/dist/components/help-center-pages/index.cjs +22 -22
  63. package/dist/components/help-center-pages/index.js +13 -13
  64. package/dist/components/index.cjs +61 -892
  65. package/dist/components/index.cjs.map +1 -1
  66. package/dist/components/index.js +244 -1075
  67. package/dist/components/index.js.map +1 -1
  68. package/dist/components/meeting-scheduler/index.cjs +18 -18
  69. package/dist/components/meeting-scheduler/index.js +1 -1
  70. package/dist/components/navigation/index.cjs +5 -5
  71. package/dist/components/navigation/index.js +4 -4
  72. package/dist/components/onboarding-guides/index.cjs +5 -5
  73. package/dist/components/onboarding-guides/index.js +4 -4
  74. package/dist/components/tickets/index.cjs +6 -6
  75. package/dist/components/tickets/index.js +5 -5
  76. package/dist/components/ui/index.cjs +2 -2
  77. package/dist/components/ui/index.js +1 -1
  78. package/dist/index.cjs +6 -6
  79. package/dist/index.js +5 -5
  80. package/dist/utils/announcement-storage.d.ts +16 -25
  81. package/dist/utils/announcement-storage.d.ts.map +1 -1
  82. package/dist/utils/dismiss-cookie.d.ts +8 -27
  83. package/dist/utils/dismiss-cookie.d.ts.map +1 -1
  84. package/dist/utils/dismissal-storage.d.ts +2 -2
  85. package/dist/utils/dismissal-storage.d.ts.map +1 -1
  86. package/dist/utils/index.cjs +53 -70
  87. package/dist/utils/index.cjs.map +1 -1
  88. package/dist/utils/index.js +53 -70
  89. package/dist/utils/index.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/components/features/.push-button-selector.md +1 -1
  92. package/src/components/features/push-button-selector.tsx +142 -17
  93. package/src/utils/announcement-storage.ts +16 -25
  94. package/src/utils/dismiss-cookie.ts +9 -86
  95. package/src/utils/dismissal-storage.ts +2 -2
  96. package/dist/chunk-2T7GXHRQ.js.map +0 -1
  97. package/dist/chunk-FWNIZC6A.js +0 -1867
  98. package/dist/chunk-FWNIZC6A.js.map +0 -1
  99. package/dist/chunk-IHFTLVDF.cjs +0 -1867
  100. package/dist/chunk-IHFTLVDF.cjs.map +0 -1
  101. package/dist/chunk-RH7EY7YN.cjs.map +0 -1
  102. package/src/utils/__tests__/dismiss-cookie.test.ts +0 -133
  103. /package/dist/{chunk-PDK6KVU6.js.map → chunk-53ZVUW2M.js.map} +0 -0
  104. /package/dist/{chunk-BTXJQPGE.js.map → chunk-5U2FU5LM.js.map} +0 -0
  105. /package/dist/{chunk-EXBNO6FB.js.map → chunk-CJW6KZD3.js.map} +0 -0
  106. /package/dist/{chunk-NOO3QOJI.js.map → chunk-EULY3BXB.js.map} +0 -0
  107. /package/dist/{chunk-ML22BIKH.js.map → chunk-GAL6EXUY.js.map} +0 -0
  108. /package/dist/{chunk-YTZK2ORN.js.map → chunk-H7K76TPX.js.map} +0 -0
  109. /package/dist/{chunk-KNQZZJYJ.js.map → chunk-IB23CRL2.js.map} +0 -0
  110. /package/dist/{chunk-XJ3Y4OOF.js.map → chunk-IZGXIZWF.js.map} +0 -0
  111. /package/dist/{chunk-MCFETAH7.js.map → chunk-JUXCEHK2.js.map} +0 -0
  112. /package/dist/{chunk-G3ZMLSKJ.js.map → chunk-SM6JAZ4C.js.map} +0 -0
  113. /package/dist/{chunk-LENIASJW.js.map → chunk-T4DIAKIK.js.map} +0 -0
  114. /package/dist/{chunk-FVF2MVIT.js.map → chunk-U6GFESVF.js.map} +0 -0
  115. /package/dist/{chunk-LFG5JG2D.js.map → chunk-ZSENCE55.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.582-1967.5390.1",
3
+ "version": "0.0.582",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,4 @@
1
- <!-- source-hash: 1b470f4e084b6cb0c7cd286c460838ca -->
1
+ <!-- source-hash: 0ccfb311d1bb254bbfb9ffdb994a9af1 -->
2
2
  A client-side React component that renders a list of toggleable push-button options with support for single/multi-select, section grouping, disabled states with tooltips, loading skeletons, and error handling.
3
3
 
4
4
  ## Key Components
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { Check } from 'lucide-react';
4
4
  import type React from 'react';
5
- import { Badge } from '../ui';
5
+ import { useEffect, useRef } from 'react';
6
+ import { UnifiedSkeleton } from '../loading';
7
+ import { Badge, Input } from '../ui';
6
8
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
7
9
 
8
10
  export interface SelectableOption {
@@ -27,6 +29,28 @@ export interface SectionDefinition {
27
29
  description?: string; // Optional description shown under section label
28
30
  }
29
31
 
32
+ /** SERVER-SIDE search: the selector renders the input; the PARENT owns the
33
+ * query state and re-fetches `options` (never a client-side .filter of a
34
+ * pre-fetched list). While `isLoading`, the input stays mounted (typing
35
+ * must not lose focus to a skeleton swap). */
36
+ export interface PushButtonSelectorSearch {
37
+ value: string;
38
+ onChange: (value: string) => void;
39
+ placeholder?: string;
40
+ ariaLabel?: string;
41
+ }
42
+
43
+ /** Lazy paging: the option list becomes a FIXED-HEIGHT scroll viewport with
44
+ * an IntersectionObserver sentinel near the bottom — the selector calls
45
+ * `onLoadMore` as the user scrolls; the parent appends to `options`. */
46
+ export interface PushButtonSelectorLazyLoad {
47
+ hasMore: boolean;
48
+ isFetchingMore: boolean;
49
+ onLoadMore: () => void;
50
+ /** CSS height of the scroll viewport (default '336px' ≈ 6 rows). */
51
+ maxHeight?: string;
52
+ }
53
+
30
54
  interface PushButtonSelectorProps {
31
55
  options: SelectableOption[];
32
56
  selectedIds: string[];
@@ -41,25 +65,37 @@ interface PushButtonSelectorProps {
41
65
  error?: string | null;
42
66
  skeletonCount?: number;
43
67
  sections?: SectionDefinition[]; // Optional sections for grouping options
68
+ /** Built-in server-side search input (see PushButtonSelectorSearch). */
69
+ search?: PushButtonSelectorSearch;
70
+ /** Built-in fixed-height scroll + infinite paging (see PushButtonSelectorLazyLoad). */
71
+ lazyLoad?: PushButtonSelectorLazyLoad;
72
+ /** Rendered inside the list when `options` is empty and not loading. */
73
+ emptyMessage?: React.ReactNode;
74
+ /** Rendered under the list (result counts, hints). */
75
+ footer?: React.ReactNode;
44
76
  }
45
77
 
46
- // Skeleton component matching external pattern from announcement-form.tsx
78
+ // Composed from the app-wide UnifiedSkeleton system (components/loading) —
79
+ // the same base every other loading surface uses (bg-ods-skeleton pulse,
80
+ // prefers-reduced-motion support, role="status" a11y). Each skeleton row
81
+ // mirrors a LOADED option row exactly (bg-ods-bg card, p-4, icon + two
82
+ // 20px-tall text lines + selection box) so the list never jumps on load.
47
83
  function PushButtonSelectorSkeleton({ count = 3, hasTitle }: { count?: number; hasTitle?: boolean }) {
48
84
  return (
49
- <div className="space-y-3">
50
- {hasTitle && <div className="h-5 w-20 animate-pulse rounded bg-ods-skeleton" />}
85
+ <div className="space-y-3" role="status" aria-label="Loading options">
86
+ {hasTitle && <UnifiedSkeleton className="h-5 w-20" aria-label="Loading title" />}
51
87
  <div className="space-y-3">
52
88
  {Array.from({ length: count }, (_, i) => (
53
- <div key={i} className="animate-pulse rounded-lg border border-ods-border bg-ods-skeleton p-4">
54
- <div className="flex items-center justify-between">
89
+ <div key={i} className="rounded-lg border border-ods-border bg-ods-bg p-4">
90
+ <div className="flex items-center justify-between gap-4">
55
91
  <div className="flex items-center gap-3">
56
- <div className="h-8 w-8 rounded bg-ods-skeleton" />
92
+ <UnifiedSkeleton className="h-8 w-8" aria-label="Loading icon" />
57
93
  <div>
58
- <div className="mb-1 h-4 w-20 rounded bg-ods-skeleton" />
59
- <div className="h-3 w-32 rounded bg-ods-skeleton" />
94
+ <UnifiedSkeleton variant="text" className="mb-2 h-4 w-24" aria-label="Loading name" />
95
+ <UnifiedSkeleton variant="text" className="h-4 w-36" aria-label="Loading description" />
60
96
  </div>
61
97
  </div>
62
- <div className="h-6 w-6 rounded border-2 border-ods-border bg-ods-skeleton" />
98
+ <UnifiedSkeleton className="h-6 w-6" aria-label="Loading selection state" />
63
99
  </div>
64
100
  </div>
65
101
  ))}
@@ -94,18 +130,68 @@ export function PushButtonSelector({
94
130
  error = null,
95
131
  skeletonCount = 3,
96
132
  sections,
133
+ search,
134
+ lazyLoad,
135
+ emptyMessage,
136
+ footer,
97
137
  }: PushButtonSelectorProps) {
98
- // LOADING STATE
99
- if (isLoading) {
138
+ // Lazy-load sentinel: observe within the scroll viewport so onLoadMore
139
+ // fires as the user approaches the bottom. Hooks run unconditionally
140
+ // (before the early returns) per the rules of hooks. Pass a STABLE
141
+ // onLoadMore (react-query's fetchNextPage is) — an inline arrow re-arms
142
+ // the observer each render (harmless, just wasteful).
143
+ const scrollRef = useRef<HTMLDivElement | null>(null);
144
+ const sentinelRef = useRef<HTMLDivElement | null>(null);
145
+ const lazyHasMore = lazyLoad?.hasMore ?? false;
146
+ const lazyFetching = lazyLoad?.isFetchingMore ?? false;
147
+ const onLoadMore = lazyLoad?.onLoadMore;
148
+ const optionCount = options.length;
149
+ useEffect(() => {
150
+ const sentinel = sentinelRef.current;
151
+ const root = scrollRef.current;
152
+ if (!sentinel || !root || !onLoadMore || !lazyHasMore) return undefined;
153
+ const observer = new IntersectionObserver(
154
+ entries => {
155
+ if (entries.some(e => e.isIntersecting) && !lazyFetching) {
156
+ onLoadMore();
157
+ }
158
+ },
159
+ { root, rootMargin: '80px' },
160
+ );
161
+ observer.observe(sentinel);
162
+ return () => {
163
+ observer.disconnect();
164
+ };
165
+ // optionCount: re-arm when the list grows (the sentinel moves).
166
+ }, [lazyHasMore, lazyFetching, onLoadMore, optionCount]);
167
+
168
+ const searchInput = search ? (
169
+ <Input
170
+ value={search.value}
171
+ onChange={e => search.onChange(e.target.value)}
172
+ placeholder={search.placeholder}
173
+ aria-label={search.ariaLabel ?? search.placeholder ?? 'Search'}
174
+ />
175
+ ) : null;
176
+
177
+ // Framed mode (search and/or lazyLoad): loading and error are NOT early
178
+ // returns — the frame (title, search input, fixed-height viewport, footer)
179
+ // must stay pixel-identical across loading/loaded/empty/error, with only
180
+ // the viewport CONTENT swapping. An early-return skeleton dropped the
181
+ // bordered viewport + footer and rendered shorter rows, so the whole
182
+ // section collapsed and jumped on every debounced search.
183
+ const framed = !!search || !!lazyLoad;
184
+
185
+ // LOADING / ERROR STATES — legacy (unframed) consumers keep the historical
186
+ // whole-component swap, byte-identical.
187
+ if (isLoading && !framed) {
100
188
  return (
101
189
  <div className={className}>
102
190
  <PushButtonSelectorSkeleton count={skeletonCount} hasTitle={!!title} />
103
191
  </div>
104
192
  );
105
193
  }
106
-
107
- // ERROR STATE
108
- if (error) {
194
+ if (error && !framed) {
109
195
  return (
110
196
  <div className={className}>
111
197
  <PushButtonSelectorError message={error} title={title} />
@@ -252,15 +338,54 @@ export function PushButtonSelector({
252
338
  );
253
339
  };
254
340
 
341
+ // ~82px per row: enough skeleton rows to fill the fixed viewport, so the
342
+ // loading state occupies the SAME height as the loaded list.
343
+ const framedSkeletonCount = lazyLoad ? Math.max(skeletonCount, 4) : skeletonCount;
344
+ const listContent = isLoading ? (
345
+ <PushButtonSelectorSkeleton count={framedSkeletonCount} />
346
+ ) : error ? (
347
+ <PushButtonSelectorError message={error} />
348
+ ) : (
349
+ <>
350
+ {options.length === 0 && emptyMessage !== undefined ? (
351
+ <div className="text-ods-text-secondary text-h6">{emptyMessage}</div>
352
+ ) : (
353
+ renderOptionsContent()
354
+ )}
355
+ {lazyLoad ? (
356
+ <>
357
+ <div ref={sentinelRef} />
358
+ {lazyLoad.isFetchingMore ? (
359
+ <div className="py-2 text-center text-ods-text-secondary text-h6">Loading more…</div>
360
+ ) : null}
361
+ </>
362
+ ) : null}
363
+ </>
364
+ );
365
+
255
366
  return (
256
367
  <TooltipProvider delayDuration={150}>
257
368
  <div className={`space-y-4 ${className}`}>
258
369
  {title && <h3 className="text-ods-text-primary text-h5">{title}</h3>}
259
370
 
260
- {renderOptionsContent()}
371
+ {searchInput}
372
+
373
+ {lazyLoad ? (
374
+ <div
375
+ ref={scrollRef}
376
+ className="space-y-4 overflow-y-auto rounded-lg border border-ods-border p-2"
377
+ style={{ height: lazyLoad.maxHeight ?? '336px' }}
378
+ >
379
+ {listContent}
380
+ </div>
381
+ ) : (
382
+ listContent
383
+ )}
384
+
385
+ {footer}
261
386
 
262
387
  {/* Selection Summary */}
263
- {selectionSummary && validSelectedIds.length > 0 && (
388
+ {!isLoading && !error && selectionSummary && validSelectedIds.length > 0 && (
264
389
  <div className="rounded-lg border border-ods-success bg-ods-success-secondary p-4">
265
390
  <div className="mb-2 flex items-center gap-2">
266
391
  <div className="h-2 w-2 rounded-full bg-ods-success"></div>
@@ -1,24 +1,18 @@
1
1
  /**
2
2
  * Announcement dismissal store.
3
3
  *
4
- * The dismissal COOKIE is the source of truth wherever cookies work: the hub's
5
- * root layout reads it server-side to decide whether to SSR-seed the bar (no
6
- * flash for dismissed users), and the client reads it via
7
- * `isAnnouncementDismissed`. One cookie per platform holding the LAST dismissed
8
- * announcement id — dismissal is an id-match, never mere cookie presence, so a
9
- * new announcement always shows after an old one was dismissed, and
10
- * re-activating an old announcement intentionally re-shows it.
11
- *
12
- * On a cookie-averse document (see `utils/dismiss-cookie.ts`) the primitive
13
- * writes `<platform>-announcement-dismissed` to localStorage instead. Do not
14
- * mistake it for the legacy key below and delete the write: it is the ONLY
15
- * thing making a dismissal persist in a shell. `clearAnnouncementDismissals`' prefix sweep does match it, which
16
- * is right — a full reset should take both.
4
+ * The dismissal COOKIE is the single source of truth: the hub's root layout
5
+ * reads it server-side to decide whether to SSR-seed the bar (no flash for
6
+ * dismissed users), and the client reads it via `isAnnouncementDismissed`.
7
+ * One cookie per platform holding the LAST dismissed announcement id —
8
+ * dismissal is an id-match, never mere cookie presence, so a new announcement
9
+ * always shows after an old one was dismissed, and re-activating an old
10
+ * announcement intentionally re-shows it.
17
11
  *
18
12
  * The legacy per-id localStorage key (`<platform>-announcement-<id>-dismissed`)
19
13
  * is READ-ONLY legacy: consulted only when no cookie exists for the platform,
20
14
  * purely as the migration trigger (the bar's mount effect backfills the
21
- * cookie). Nothing writes it any more, so that store dies out.
15
+ * cookie). New dismissals write the cookie only, so the second store dies out.
22
16
  *
23
17
  * No `"use client"` directive on purpose — `announcementDismissCookieName` and
24
18
  * `isDismissedCookieValue` are pure/isomorphic and imported by the hub's
@@ -42,21 +36,18 @@ export { isDismissedCookieValue } from './dismiss-cookie';
42
36
 
43
37
  const legacyDismissKey = (platform: string, id: string) => `${platform}-announcement-${id}-dismissed`;
44
38
 
45
- /** Persist a dismissal (1 year). The SSR layout sees it on the next request —
46
- * except in a native shell, where the primitive falls back to localStorage and
47
- * there is no SSR layout to see it anyway. The LEGACY per-id key is still never
48
- * written. */
39
+ /** Persist a dismissal: cookie only (1 year). The SSR layout sees it on the
40
+ * next request; localStorage is intentionally NOT written (read-only legacy). */
49
41
  export function dismissAnnouncement(platform: string, id: string): void {
50
42
  if (typeof document === 'undefined') return;
51
43
  writeDismissCookie(announcementDismissCookieName(platform), id);
52
44
  }
53
45
 
54
46
  /**
55
- * Client-side dismissal check — primary store first, with the legacy
56
- * localStorage key consulted ONLY when that comes back empty. Reads browser
57
- * storage, so callers must invoke it
58
- * from effects, never during render (a render-time read would desync hydration
59
- * from the SSR HTML).
47
+ * Client-side dismissal check — cookie-first: the legacy localStorage key is
48
+ * consulted ONLY when no cookie exists for the platform. Reads browser
49
+ * storage, so callers must invoke it from effects, never during render
50
+ * (a render-time read would desync hydration from the SSR HTML).
60
51
  */
61
52
  export function isAnnouncementDismissed(platform: string, id: string): boolean {
62
53
  if (typeof document === 'undefined') return false;
@@ -69,8 +60,8 @@ export function isAnnouncementDismissed(platform: string, id: string): boolean {
69
60
  }
70
61
  }
71
62
 
72
- /** Remove ALL dismissal state for a platform — test/story helper so callers
73
- * never restate the key encoding. The header owns the tier inventory. */
63
+ /** Remove ALL dismissal state for a platform (cookie + legacy localStorage
64
+ * keys) — test/story helper so callers never restate the key encoding. */
74
65
  export function clearAnnouncementDismissals(platform: string): void {
75
66
  if (typeof document === 'undefined') return;
76
67
  clearDismissCookie(announcementDismissCookieName(platform));
@@ -3,92 +3,31 @@
3
3
  * dismissed", shared by every dismissible surface (announcement bar, floating
4
4
  * walkthrough video, and anything added later).
5
5
  *
6
- * The dismissal rule is an ID MATCH, never mere cookie presence: the store
7
- * holds the id of the LAST dismissed item, so publishing a NEW item re-shows
6
+ * The dismissal rule is an ID MATCH, never mere cookie presence: the cookie
7
+ * stores the id of the LAST dismissed item, so publishing a NEW item re-shows
8
8
  * the surface while the old dismissal still suppresses the old item.
9
9
  *
10
- * TWO TIERS. The cookie is primary wherever cookies work; on a cookie-averse
11
- * document `localStorage` takes over under the same key. See `canUseCookies`
12
- * for when that is, and `fallbackStore` for why it is a fallback and not a
13
- * mirror.
14
- *
15
10
  * No `"use client"` on purpose — `isDismissedCookieValue` is pure and is used by
16
11
  * server code (the hub's root layout reads the cookie during SSR to avoid a
17
12
  * flash); the DOM-touching helpers guard on `typeof document`.
18
13
  */
19
14
 
20
- import { createLocalStorageAdapter } from './local-storage-adapter';
21
-
22
- /** One year — the COOKIE tier's horizon. The fallback tier has no expiry, so a
23
- * dismissal in a native shell is permanent; adding an expiry envelope there
24
- * would be inventing a second encoding to answer a question nobody has asked. */
15
+ /** One year, the standard dismissal horizon for these surfaces. */
25
16
  const DISMISS_COOKIE_MAX_AGE_SECONDS = 31_536_000;
26
17
 
27
- /**
28
- * Whether `document.cookie` actually stores anything here.
29
- *
30
- * A Document whose URL scheme is not http(s) is COOKIE-AVERSE — the HTML spec's
31
- * term for one where the `document.cookie` setter is a silent no-op and the
32
- * getter returns ''. That is where the custom-scheme shells run: Tauri serves
33
- * the bundle from `tauri://localhost` on macOS/Linux, Capacitor from
34
- * `capacitor://localhost` on iOS. Every dismissal there was dropped on write
35
- * and unreadable on the next mount, so the surface came back in every new
36
- * window and on every launch. Tauri on Windows and Capacitor on Android serve
37
- * http(s), were never affected, and stay on the cookie tier.
38
- *
39
- * The scheme is read directly rather than probed with a throwaway write: a lib
40
- * whose consumers are consent-regulated marketing sites should not set a
41
- * cookie, however briefly, to answer a question the URL already answers.
42
- * Deliberately NOT covered: cookies blocked by user or enterprise policy on an
43
- * http(s) origin — the surface reappears there exactly as it always has, which
44
- * is what someone who blocked cookies asked for.
45
- *
46
- * Read off `document.URL`, not `window.location`: averseness is a property of
47
- * the DOCUMENT's URL, and this file must stay usable wherever `document` is the
48
- * only thing the other helpers here already require. `about:blank` and
49
- * `about:srcdoc` are the one miss: they carry their creator's origin and are
50
- * served its cookies, yet land on the fallback tier here. Accepted — nothing
51
- * hosts this lib in one, and the cost is a re-show, not a broken surface.
52
- */
53
- function canUseCookies(): boolean {
54
- if (typeof document === 'undefined') return false;
55
- return document.URL.startsWith('http:') || document.URL.startsWith('https:');
56
- }
57
-
58
- /**
59
- * The fallback tier for cookie-averse documents, keyed identically to the
60
- * cookie so a host needs no second key.
61
- *
62
- * A FALLBACK, never a mirror: cookies stay primary wherever they work, because
63
- * they are the only tier a server can read — which is what lets the hub
64
- * suppress a dismissed surface during SSR instead of flashing it — and writing
65
- * both would mean clearing cookies no longer clears a dismissal.
66
- */
67
- function fallbackStore(name: string) {
68
- return createLocalStorageAdapter<string>({
69
- key: name,
70
- logTag: '[dismiss-cookie]',
71
- validate: (parsed): parsed is string => typeof parsed === 'string',
72
- });
73
- }
74
-
75
18
  /** THE dismissal match rule. `undefined` id (nothing active) → not dismissed. */
76
19
  export function isDismissedCookieValue(cookieValue: string | undefined, id: string | undefined): boolean {
77
20
  return !!id && cookieValue === id;
78
21
  }
79
22
 
80
23
  /**
81
- * Read a dismissal. Returns `undefined` when absent OR unreadable — one
82
- * guarantee with two causes: a cookie with bad percent-encoding, where
83
- * `decodeURIComponent` throws `URIError`, and a fallback value that is not the
84
- * JSON string it should be, where the adapter swallows the parse failure.
85
- * Callers run this inside reveal paths where an uncaught throw would hide the
86
- * surface permanently, so failing open — treated as not-dismissed — is always
87
- * the safe side.
24
+ * Read a dismissal cookie. Returns `undefined` when absent OR malformed:
25
+ * `decodeURIComponent` throws `URIError` on bad percent-encoding, and callers
26
+ * run this inside reveal paths where an uncaught throw would hide the surface
27
+ * permanently. Failing open (treated as not-dismissed) is always the safe side.
88
28
  */
89
29
  export function readDismissCookie(name: string): string | undefined {
90
30
  if (typeof document === 'undefined') return undefined;
91
- if (!canUseCookies()) return fallbackStore(name).load() ?? undefined;
92
31
  const match = document.cookie.split('; ').find(row => row.startsWith(`${name}=`));
93
32
  if (!match) return undefined;
94
33
  try {
@@ -98,30 +37,14 @@ export function readDismissCookie(name: string): string | undefined {
98
37
  }
99
38
  }
100
39
 
101
- /** Persist a dismissal. */
40
+ /** Persist a dismissal (cookie only, 1 year). */
102
41
  export function writeDismissCookie(name: string, id: string): void {
103
42
  if (typeof document === 'undefined') return;
104
- if (!canUseCookies()) {
105
- fallbackStore(name).save(id);
106
- return;
107
- }
108
43
  document.cookie = `${name}=${encodeURIComponent(id)}; path=/; max-age=${DISMISS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
109
44
  }
110
45
 
111
- /**
112
- * Clear a dismissal (test/story helper).
113
- *
114
- * BOTH tiers, unconditionally — the tier check governs writes, not removal.
115
- * Clearing writes nothing and cannot resurrect a dismissal, so it carries none
116
- * of the reasons the write path commits to one tier, and a clear that leaves
117
- * the other tier holding a value is a lie.
118
- *
119
- * NOT for a shell that changes the scheme it serves under: that is an origin
120
- * change, so the old tier moves to a storage bucket no code in the new origin
121
- * can reach — `clear` included.
122
- */
46
+ /** Clear a dismissal (test/story helper). */
123
47
  export function clearDismissCookie(name: string): void {
124
48
  if (typeof document === 'undefined') return;
125
- fallbackStore(name).clear();
126
49
  document.cookie = `${name}=; path=/; max-age=0`;
127
50
  }
@@ -22,8 +22,8 @@ export function walkthroughDismissCookieName(platform: string): string {
22
22
  return `${platform}-${WALKTHROUGH_VIDEO_DISMISS_KEY}`;
23
23
  }
24
24
 
25
- /** Client-side dismissal check (id-match). Reads browser storage, so call it
26
- * from an effect, never during render. */
25
+ /** Client-side dismissal check (id-match). Reads a cookie, so call it from an
26
+ * effect, never during render. */
27
27
  export function isWalkthroughDismissed(key: string, id: string | undefined): boolean {
28
28
  return isDismissedCookieValue(readDismissCookie(key), id);
29
29
  }