@flamingo-stack/openframe-frontend-core 0.0.583 → 0.0.584-1967.5424.1

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 (105) hide show
  1. package/dist/{chunk-BTBW4L2W.cjs → chunk-33DHEEMP.cjs} +33 -33
  2. package/dist/{chunk-BTBW4L2W.cjs.map → chunk-33DHEEMP.cjs.map} +1 -1
  3. package/dist/{chunk-XZ3MKTJZ.js → chunk-3ELW4VML.js} +2 -2
  4. package/dist/{chunk-WTC6QQNX.cjs → chunk-3ZA2OYRE.cjs} +3 -3
  5. package/dist/{chunk-WTC6QQNX.cjs.map → chunk-3ZA2OYRE.cjs.map} +1 -1
  6. package/dist/{chunk-J3EEL6YC.js → chunk-4T5KOD3G.js} +4 -4
  7. package/dist/{chunk-HIHPWVKP.js → chunk-5CJKZHWO.js} +5 -5
  8. package/dist/{chunk-VWHEXQ64.js → chunk-6BJYPG2V.js} +2 -2
  9. package/dist/{chunk-WBTKXNKZ.cjs → chunk-7X5TCK4R.cjs} +4 -4
  10. package/dist/{chunk-WBTKXNKZ.cjs.map → chunk-7X5TCK4R.cjs.map} +1 -1
  11. package/dist/{chunk-IOP7BCRQ.js → chunk-EJKEDCTH.js} +5 -5
  12. package/dist/{chunk-OCXFFGFA.cjs → chunk-HCKEDTKJ.cjs} +10 -10
  13. package/dist/{chunk-OCXFFGFA.cjs.map → chunk-HCKEDTKJ.cjs.map} +1 -1
  14. package/dist/{chunk-EATPU5EF.cjs → chunk-IIT5HWLY.cjs} +3 -3
  15. package/dist/{chunk-EATPU5EF.cjs.map → chunk-IIT5HWLY.cjs.map} +1 -1
  16. package/dist/{chunk-RBDVHLT5.cjs → chunk-JAJQ5P2Q.cjs} +17 -17
  17. package/dist/{chunk-RBDVHLT5.cjs.map → chunk-JAJQ5P2Q.cjs.map} +1 -1
  18. package/dist/{chunk-YNLIXABT.cjs → chunk-JFKK3ZOA.cjs} +34 -34
  19. package/dist/{chunk-YNLIXABT.cjs.map → chunk-JFKK3ZOA.cjs.map} +1 -1
  20. package/dist/{chunk-PFKWJ6PJ.js → chunk-JVWRMLQZ.js} +18 -1
  21. package/dist/chunk-JVWRMLQZ.js.map +1 -0
  22. package/dist/{chunk-WMRXB6RS.js → chunk-KP6VJP36.js} +2 -2
  23. package/dist/{chunk-FCLM2RXI.js → chunk-LNKULR4V.js} +2 -2
  24. package/dist/{chunk-3TNM6SDP.cjs → chunk-MEJFHD5N.cjs} +13 -13
  25. package/dist/{chunk-3TNM6SDP.cjs.map → chunk-MEJFHD5N.cjs.map} +1 -1
  26. package/dist/{chunk-EZ5VU5WS.js → chunk-MYEG5ZDT.js} +2 -2
  27. package/dist/{chunk-KDWNMFX4.cjs → chunk-NVY2ZP7Z.cjs} +17 -17
  28. package/dist/{chunk-KDWNMFX4.cjs.map → chunk-NVY2ZP7Z.cjs.map} +1 -1
  29. package/dist/{chunk-TRD2YICZ.cjs → chunk-P2VRLSAP.cjs} +16 -16
  30. package/dist/{chunk-TRD2YICZ.cjs.map → chunk-P2VRLSAP.cjs.map} +1 -1
  31. package/dist/{chunk-6BI3AYBY.cjs → chunk-Q66AMADB.cjs} +4 -4
  32. package/dist/{chunk-6BI3AYBY.cjs.map → chunk-Q66AMADB.cjs.map} +1 -1
  33. package/dist/{chunk-M6ZUEBDK.cjs → chunk-QJR32JM2.cjs} +18 -1
  34. package/dist/chunk-QJR32JM2.cjs.map +1 -0
  35. package/dist/{chunk-PMA7DVQR.js → chunk-RHPCSFJH.js} +3 -3
  36. package/dist/{chunk-R5A3MGMY.js → chunk-RURZ6QLN.js} +2 -2
  37. package/dist/{chunk-L26MD32B.js → chunk-TLKHLGAB.js} +2 -2
  38. package/dist/{chunk-3RR67XC2.cjs → chunk-UCNEQKIT.cjs} +3 -3
  39. package/dist/{chunk-3RR67XC2.cjs.map → chunk-UCNEQKIT.cjs.map} +1 -1
  40. package/dist/{chunk-IJVWKLJK.cjs → chunk-VOAZU46B.cjs} +49 -49
  41. package/dist/{chunk-IJVWKLJK.cjs.map → chunk-VOAZU46B.cjs.map} +1 -1
  42. package/dist/{chunk-DROXHJVZ.js → chunk-VZSTL5VG.js} +2 -2
  43. package/dist/{chunk-AJLF6MBD.js → chunk-XPJKQ63S.js} +2 -2
  44. package/dist/{chunk-WHNC7C76.js → chunk-YFZDIEVC.js} +4 -4
  45. package/dist/{chunk-IFPGXSMZ.cjs → chunk-ZHUBNWEX.cjs} +3 -3
  46. package/dist/{chunk-IFPGXSMZ.cjs.map → chunk-ZHUBNWEX.cjs.map} +1 -1
  47. package/dist/components/case-studies/index.cjs +8 -8
  48. package/dist/components/case-studies/index.js +2 -2
  49. package/dist/components/contact/index.cjs +3 -3
  50. package/dist/components/contact/index.js +2 -2
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +3 -3
  54. package/dist/components/embeds/index.js +2 -2
  55. package/dist/components/faq/index.cjs +3 -3
  56. package/dist/components/faq/index.js +2 -2
  57. package/dist/components/features/index.cjs +2 -2
  58. package/dist/components/features/index.js +1 -1
  59. package/dist/components/help-center-pages/index.cjs +22 -22
  60. package/dist/components/help-center-pages/index.js +13 -13
  61. package/dist/components/index.cjs +29 -29
  62. package/dist/components/index.js +12 -12
  63. package/dist/components/meeting-scheduler/index.cjs +18 -18
  64. package/dist/components/meeting-scheduler/index.js +1 -1
  65. package/dist/components/navigation/index.cjs +5 -5
  66. package/dist/components/navigation/index.js +4 -4
  67. package/dist/components/onboarding-guides/index.cjs +5 -5
  68. package/dist/components/onboarding-guides/index.js +4 -4
  69. package/dist/components/tickets/index.cjs +6 -6
  70. package/dist/components/tickets/index.js +5 -5
  71. package/dist/components/ui/index.cjs +2 -2
  72. package/dist/components/ui/index.js +1 -1
  73. package/dist/index.cjs +6 -6
  74. package/dist/index.js +5 -5
  75. package/dist/utils/announcement-storage.d.ts +25 -16
  76. package/dist/utils/announcement-storage.d.ts.map +1 -1
  77. package/dist/utils/dismiss-cookie.d.ts +27 -8
  78. package/dist/utils/dismiss-cookie.d.ts.map +1 -1
  79. package/dist/utils/dismissal-storage.d.ts +2 -2
  80. package/dist/utils/dismissal-storage.d.ts.map +1 -1
  81. package/dist/utils/index.cjs +70 -53
  82. package/dist/utils/index.cjs.map +1 -1
  83. package/dist/utils/index.js +70 -53
  84. package/dist/utils/index.js.map +1 -1
  85. package/package.json +1 -1
  86. package/src/utils/__tests__/dismiss-cookie.test.ts +133 -0
  87. package/src/utils/announcement-storage.ts +25 -16
  88. package/src/utils/dismiss-cookie.ts +86 -9
  89. package/src/utils/dismissal-storage.ts +2 -2
  90. package/dist/chunk-M6ZUEBDK.cjs.map +0 -1
  91. package/dist/chunk-PFKWJ6PJ.js.map +0 -1
  92. /package/dist/{chunk-XZ3MKTJZ.js.map → chunk-3ELW4VML.js.map} +0 -0
  93. /package/dist/{chunk-J3EEL6YC.js.map → chunk-4T5KOD3G.js.map} +0 -0
  94. /package/dist/{chunk-HIHPWVKP.js.map → chunk-5CJKZHWO.js.map} +0 -0
  95. /package/dist/{chunk-VWHEXQ64.js.map → chunk-6BJYPG2V.js.map} +0 -0
  96. /package/dist/{chunk-IOP7BCRQ.js.map → chunk-EJKEDCTH.js.map} +0 -0
  97. /package/dist/{chunk-WMRXB6RS.js.map → chunk-KP6VJP36.js.map} +0 -0
  98. /package/dist/{chunk-FCLM2RXI.js.map → chunk-LNKULR4V.js.map} +0 -0
  99. /package/dist/{chunk-EZ5VU5WS.js.map → chunk-MYEG5ZDT.js.map} +0 -0
  100. /package/dist/{chunk-PMA7DVQR.js.map → chunk-RHPCSFJH.js.map} +0 -0
  101. /package/dist/{chunk-R5A3MGMY.js.map → chunk-RURZ6QLN.js.map} +0 -0
  102. /package/dist/{chunk-L26MD32B.js.map → chunk-TLKHLGAB.js.map} +0 -0
  103. /package/dist/{chunk-DROXHJVZ.js.map → chunk-VZSTL5VG.js.map} +0 -0
  104. /package/dist/{chunk-AJLF6MBD.js.map → chunk-XPJKQ63S.js.map} +0 -0
  105. /package/dist/{chunk-WHNC7C76.js.map → chunk-YFZDIEVC.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.583",
3
+ "version": "0.0.584-1967.5424.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tier selection on both sides of the cookie-averse split, plus the match rule
3
+ * the two tiers feed.
4
+ *
5
+ * `document.URL` is stubbed rather than the document navigated: jsdom will not
6
+ * take a `tauri://` document URL, and the scheme is the only input the tier
7
+ * choice reads — so the stub sits exactly at the seam production reads.
8
+ *
9
+ * The round-trips run against the real stores rather than spies: reading a
10
+ * value back is the only proof a write actually stuck, which is the whole
11
+ * subject of this file.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
+ import { clearDismissCookie, isDismissedCookieValue, readDismissCookie, writeDismissCookie } from '../dismiss-cookie';
15
+
16
+ const KEY = 'openframe-walkthrough-video-dismissed';
17
+ const nativeUrlDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'URL');
18
+
19
+ function setDocumentUrl(url: string): void {
20
+ Object.defineProperty(document, 'URL', { value: url, writable: true, configurable: true });
21
+ }
22
+
23
+ afterEach(() => {
24
+ // Restore the native accessor rather than re-stubbing a hardcoded URL, which
25
+ // would silently diverge from jsdom's own document URL if the vitest
26
+ // environment is ever given an explicit one.
27
+ if (nativeUrlDescriptor) Object.defineProperty(document, 'URL', nativeUrlDescriptor);
28
+ clearDismissCookie(KEY);
29
+ });
30
+
31
+ describe('dismissal on an http(s) origin', () => {
32
+ beforeEach(() => setDocumentUrl('https://openframe.ai/dashboard'));
33
+
34
+ it('round-trips through the cookie', () => {
35
+ writeDismissCookie(KEY, 'video-7');
36
+ expect(document.cookie).toContain(`${KEY}=video-7`);
37
+ expect(readDismissCookie(KEY)).toBe('video-7');
38
+ });
39
+
40
+ it('takes the cookie tier on plain http too', () => {
41
+ // Dev runs on `http://localhost`. Were only `https:` accepted, it would
42
+ // silently drop to the fallback tier, where SSR cannot see the dismissal.
43
+ setDocumentUrl('http://localhost:3000/dashboard');
44
+ writeDismissCookie(KEY, 'video-7');
45
+ expect(document.cookie).toContain(`${KEY}=video-7`);
46
+ });
47
+
48
+ it('leaves the fallback tier untouched', () => {
49
+ // Pins fallback-not-mirror; see `fallbackStore`.
50
+ writeDismissCookie(KEY, 'video-7');
51
+ expect(window.localStorage.getItem(KEY)).toBeNull();
52
+ });
53
+
54
+ it('fails open on a malformed cookie value', () => {
55
+ // `decodeURIComponent` throws `URIError` on bad percent-encoding; the other
56
+ // half of the guarantee `readDismissCookie` documents.
57
+ document.cookie = `${KEY}=%E0%A4%A; path=/`;
58
+ expect(readDismissCookie(KEY)).toBeUndefined();
59
+ });
60
+
61
+ it('clears the cookie tier', () => {
62
+ writeDismissCookie(KEY, 'video-7');
63
+ clearDismissCookie(KEY);
64
+ expect(document.cookie).not.toContain(KEY);
65
+ expect(readDismissCookie(KEY)).toBeUndefined();
66
+ });
67
+ });
68
+
69
+ describe('dismissal on a cookie-averse origin (tauri://, capacitor://)', () => {
70
+ beforeEach(() => setDocumentUrl('tauri://localhost/index.html'));
71
+
72
+ it('writes no cookie', () => {
73
+ writeDismissCookie(KEY, 'video-7');
74
+ expect(document.cookie).not.toContain(KEY);
75
+ });
76
+
77
+ it('persists through localStorage and keeps the id-match rule', () => {
78
+ writeDismissCookie(KEY, 'video-7');
79
+ // jsdom keys its cookie jar off the REAL document URL, not the stub above,
80
+ // so a cookie-only implementation's write would still land here and serve
81
+ // the reads below. Emptying the jar first is what makes them prove the
82
+ // value came from the fallback tier; the assertion confirms the wipe took,
83
+ // since a path or domain mismatch would leave the cookie in place and
84
+ // hollow out both reads.
85
+ document.cookie = `${KEY}=; path=/; max-age=0`;
86
+ expect(document.cookie).not.toContain(KEY);
87
+
88
+ // The positive half is the discriminator — asserting only the `video-8`
89
+ // false would also pass against a fallback tier that stored nothing.
90
+ expect(isDismissedCookieValue(readDismissCookie(KEY), 'video-7')).toBe(true);
91
+ expect(isDismissedCookieValue(readDismissCookie(KEY), 'video-8')).toBe(false);
92
+ });
93
+
94
+ it('fails open on a corrupted stored value', () => {
95
+ // Seeded through the real writer first, so this also pins the fallback key
96
+ // to the cookie name: read a different key and the pre-corruption value
97
+ // would still come back.
98
+ writeDismissCookie(KEY, 'video-7');
99
+ window.localStorage.setItem(KEY, 'not-json');
100
+ // Callers reveal the surface off this read, so an unreadable value must
101
+ // mean "not dismissed" rather than throwing and hiding it permanently.
102
+ expect(readDismissCookie(KEY)).toBeUndefined();
103
+
104
+ // Parses, but is not the string the tier stores — what `validate` is for.
105
+ window.localStorage.setItem(KEY, '123');
106
+ expect(readDismissCookie(KEY)).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe('the match rule itself', () => {
111
+ it('treats "nothing stored, nothing active" as NOT dismissed', () => {
112
+ // Both sides undefined is the case the `!!id` guard exists for: a bare
113
+ // equality would call it dismissed and suppress a surface that has no
114
+ // active item to be dismissed against.
115
+ expect(isDismissedCookieValue(undefined, undefined)).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe('clearing across the tier split', () => {
120
+ it('clears the tier the current scheme does NOT select', () => {
121
+ // Written on the fallback tier, cleared from a document on the cookie tier
122
+ // — the case a same-origin `blob:`/`about:blank` child produces, and the
123
+ // one a tier-checking clear would leave stranded.
124
+ setDocumentUrl('tauri://localhost/index.html');
125
+ writeDismissCookie(KEY, 'video-7');
126
+
127
+ setDocumentUrl('https://openframe.ai/dashboard');
128
+ clearDismissCookie(KEY);
129
+
130
+ setDocumentUrl('tauri://localhost/index.html');
131
+ expect(readDismissCookie(KEY)).toBeUndefined();
132
+ });
133
+ });
@@ -1,18 +1,24 @@
1
1
  /**
2
2
  * Announcement dismissal store.
3
3
  *
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.
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.
11
17
  *
12
18
  * The legacy per-id localStorage key (`<platform>-announcement-<id>-dismissed`)
13
19
  * is READ-ONLY legacy: consulted only when no cookie exists for the platform,
14
20
  * purely as the migration trigger (the bar's mount effect backfills the
15
- * cookie). New dismissals write the cookie only, so the second store dies out.
21
+ * cookie). Nothing writes it any more, so that store dies out.
16
22
  *
17
23
  * No `"use client"` directive on purpose — `announcementDismissCookieName` and
18
24
  * `isDismissedCookieValue` are pure/isomorphic and imported by the hub's
@@ -36,18 +42,21 @@ export { isDismissedCookieValue } from './dismiss-cookie';
36
42
 
37
43
  const legacyDismissKey = (platform: string, id: string) => `${platform}-announcement-${id}-dismissed`;
38
44
 
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). */
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. */
41
49
  export function dismissAnnouncement(platform: string, id: string): void {
42
50
  if (typeof document === 'undefined') return;
43
51
  writeDismissCookie(announcementDismissCookieName(platform), id);
44
52
  }
45
53
 
46
54
  /**
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).
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).
51
60
  */
52
61
  export function isAnnouncementDismissed(platform: string, id: string): boolean {
53
62
  if (typeof document === 'undefined') return false;
@@ -60,8 +69,8 @@ export function isAnnouncementDismissed(platform: string, id: string): boolean {
60
69
  }
61
70
  }
62
71
 
63
- /** Remove ALL dismissal state for a platform (cookie + legacy localStorage
64
- * keys) — test/story helper so callers never restate the key encoding. */
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. */
65
74
  export function clearAnnouncementDismissals(platform: string): void {
66
75
  if (typeof document === 'undefined') return;
67
76
  clearDismissCookie(announcementDismissCookieName(platform));
@@ -3,31 +3,92 @@
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 cookie
7
- * stores 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 store
7
+ * holds 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
+ *
10
15
  * No `"use client"` on purpose — `isDismissedCookieValue` is pure and is used by
11
16
  * server code (the hub's root layout reads the cookie during SSR to avoid a
12
17
  * flash); the DOM-touching helpers guard on `typeof document`.
13
18
  */
14
19
 
15
- /** One year, the standard dismissal horizon for these surfaces. */
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. */
16
25
  const DISMISS_COOKIE_MAX_AGE_SECONDS = 31_536_000;
17
26
 
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
+
18
75
  /** THE dismissal match rule. `undefined` id (nothing active) → not dismissed. */
19
76
  export function isDismissedCookieValue(cookieValue: string | undefined, id: string | undefined): boolean {
20
77
  return !!id && cookieValue === id;
21
78
  }
22
79
 
23
80
  /**
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.
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.
28
88
  */
29
89
  export function readDismissCookie(name: string): string | undefined {
30
90
  if (typeof document === 'undefined') return undefined;
91
+ if (!canUseCookies()) return fallbackStore(name).load() ?? undefined;
31
92
  const match = document.cookie.split('; ').find(row => row.startsWith(`${name}=`));
32
93
  if (!match) return undefined;
33
94
  try {
@@ -37,14 +98,30 @@ export function readDismissCookie(name: string): string | undefined {
37
98
  }
38
99
  }
39
100
 
40
- /** Persist a dismissal (cookie only, 1 year). */
101
+ /** Persist a dismissal. */
41
102
  export function writeDismissCookie(name: string, id: string): void {
42
103
  if (typeof document === 'undefined') return;
104
+ if (!canUseCookies()) {
105
+ fallbackStore(name).save(id);
106
+ return;
107
+ }
43
108
  document.cookie = `${name}=${encodeURIComponent(id)}; path=/; max-age=${DISMISS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
44
109
  }
45
110
 
46
- /** Clear a dismissal (test/story helper). */
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
+ */
47
123
  export function clearDismissCookie(name: string): void {
48
124
  if (typeof document === 'undefined') return;
125
+ fallbackStore(name).clear();
49
126
  document.cookie = `${name}=; path=/; max-age=0`;
50
127
  }
@@ -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 a cookie, so call it from an
26
- * effect, never during render. */
25
+ /** Client-side dismissal check (id-match). Reads browser storage, so call it
26
+ * from an effect, never during render. */
27
27
  export function isWalkthroughDismissed(key: string, id: string | undefined): boolean {
28
28
  return isDismissedCookieValue(readDismissCookie(key), id);
29
29
  }