From d345ec3e9afa47450fcec0a1188a3f090a2c6aa2 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 18:32:24 +0300 Subject: [PATCH 01/12] feat(media): allow announcement as a media owner type --- src/components/media/MediaUploader.tsx | 4 ++-- .../media/__tests__/media-owner-type.test.ts | 10 ++++++++++ src/components/media/index.ts | 3 ++- src/components/media/types.ts | 13 ++++++++++++- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 src/components/media/__tests__/media-owner-type.test.ts diff --git a/src/components/media/MediaUploader.tsx b/src/components/media/MediaUploader.tsx index dc352a9..a47f403 100644 --- a/src/components/media/MediaUploader.tsx +++ b/src/components/media/MediaUploader.tsx @@ -9,14 +9,14 @@ import { sourceSizeLimit, humanSize, } from './media-limits.js'; -import type { MediaKind, MediaUploadFn, MediaLimit } from './types.js'; +import type { MediaKind, MediaUploadFn, MediaLimit, MediaOwnerType } from './types.js'; import { Input } from '../Input.js'; import { BannerCropEditor } from './BannerCropEditor.js'; import type { CropRect } from './crop-rect.js'; export interface MediaUploaderProps { kind: MediaKind; - ownerType: 'project' | 'organization' | 'quest' | 'achievement' | 'track'; + ownerType: MediaOwnerType; ownerId: string; value?: string | null; onChange: (url: string | null) => void; diff --git a/src/components/media/__tests__/media-owner-type.test.ts b/src/components/media/__tests__/media-owner-type.test.ts new file mode 100644 index 0000000..f969333 --- /dev/null +++ b/src/components/media/__tests__/media-owner-type.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { MEDIA_OWNER_TYPES } from '../types.js'; + +describe('MEDIA_OWNER_TYPES', () => { + it('lists every owner type the API accepts, including announcement', () => { + expect([...MEDIA_OWNER_TYPES]).toEqual([ + 'project', 'organization', 'quest', 'achievement', 'track', 'announcement', + ]); + }); +}); diff --git a/src/components/media/index.ts b/src/components/media/index.ts index 204c9d4..fa5ea68 100644 --- a/src/components/media/index.ts +++ b/src/components/media/index.ts @@ -13,4 +13,5 @@ export { type AchievementPreviewSummary, } from './ProjectPagePreview.js'; export { MEDIA_LIMITS, isMimeAllowed, isSizeAllowed, humanSize } from './media-limits.js'; -export type { MediaKind, MediaMime, MediaLimit, MediaUploadFn, MediaUploadResult } from './types.js'; +export type { MediaKind, MediaMime, MediaLimit, MediaUploadFn, MediaUploadResult, MediaOwnerType } from './types.js'; +export { MEDIA_OWNER_TYPES } from './types.js'; diff --git a/src/components/media/types.ts b/src/components/media/types.ts index dc248ee..ba27df8 100644 --- a/src/components/media/types.ts +++ b/src/components/media/types.ts @@ -1,6 +1,17 @@ export type MediaKind = 'logo' | 'banner' | 'screenshot' | 'image' | 'background'; export type MediaMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/svg+xml'; +/** + * Every entity that can own an uploaded asset. Mirrors OWNER_TYPES in + * sphere-api's src/lib/media-limits.ts — the two must stay in step, since the + * value is sent verbatim to /api/upload/presign. + */ +export const MEDIA_OWNER_TYPES = [ + 'project', 'organization', 'quest', 'achievement', 'track', 'announcement', +] as const; + +export type MediaOwnerType = typeof MEDIA_OWNER_TYPES[number]; + export interface MediaLimit { mimes: readonly MediaMime[]; maxSize: number; @@ -18,7 +29,7 @@ export interface MediaUploadResult { export interface MediaUploadFn { (file: File, opts: { kind: MediaKind; - ownerType: 'project' | 'organization' | 'quest' | 'achievement' | 'track'; + ownerType: MediaOwnerType; ownerId: string; onProgress?: (pct: number) => void; signal?: AbortSignal; From ee1ba3a58782c39bc47db507b365c59bf8ce1d26 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 21:24:12 +0300 Subject: [PATCH 02/12] feat(announcements): add the client port, theme and feed hook --- .../__tests__/useAnnouncements.test.tsx | 132 ++++++++++++++++++ src/components/announcements/theme.ts | 61 ++++++++ src/components/announcements/types.ts | 40 ++++++ .../announcements/useAnnouncements.ts | 129 +++++++++++++++++ src/styles/tokens.css | 11 ++ 5 files changed, 373 insertions(+) create mode 100644 src/components/announcements/__tests__/useAnnouncements.test.tsx create mode 100644 src/components/announcements/theme.ts create mode 100644 src/components/announcements/types.ts create mode 100644 src/components/announcements/useAnnouncements.ts diff --git a/src/components/announcements/__tests__/useAnnouncements.test.tsx b/src/components/announcements/__tests__/useAnnouncements.test.tsx new file mode 100644 index 0000000..ea1bbed --- /dev/null +++ b/src/components/announcements/__tests__/useAnnouncements.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAnnouncements, __resetSessionModalFlag } from '../useAnnouncements.js'; +import type { AnnouncementsClient, AnnouncementFeed } from '../types.js'; + +function feed(over: Partial = {}): AnnouncementFeed { + return { + items: [{ + id: 'a1', priority: 'major', type: 'release', title: 'T', summary: 'S', body: 'B', + heroUrl: null, cta: null, publishAt: '2026-07-01T00:00:00.000Z', expiresAt: null, read: false, + }], + unreadCount: 1, + autoOpen: 'a1', + prefs: { autoOpenEnabled: true }, + ...over, + }; +} + +function makeClient(over: Partial = {}): AnnouncementsClient { + return { + getFeed: vi.fn().mockResolvedValue(feed()), + markRead: vi.fn().mockResolvedValue(undefined), + markAllRead: vi.fn().mockResolvedValue(undefined), + recordClick: vi.fn().mockResolvedValue(undefined), + setPrefs: vi.fn().mockResolvedValue(undefined), + getArchive: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), + ...over, + }; +} + +beforeEach(() => { localStorage.clear(); __resetSessionModalFlag(); }); + +describe('useAnnouncements', () => { + it('loads the feed and exposes the unread count', async () => { + const { result } = renderHook(() => useAnnouncements(makeClient())); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.items).toHaveLength(1); + expect(result.current.unreadCount).toBe(1); + }); + + it('surfaces the server\'s auto-open choice', async () => { + const { result } = renderHook(() => useAnnouncements(makeClient())); + await waitFor(() => expect(result.current.autoOpenId).toBe('a1')); + }); + + it('shows at most one modal per session even across remounts', async () => { + const client = makeClient(); + const first = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(first.result.current.autoOpenId).toBe('a1')); + act(() => { first.result.current.dismissModal(); }); + first.unmount(); + + const second = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(second.result.current.isLoading).toBe(false)); + expect(second.result.current.autoOpenId).toBeNull(); + }); + + it('does not re-open a modal the user already dismissed on this device', async () => { + const client = makeClient(); + const first = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(first.result.current.autoOpenId).toBe('a1')); + await act(async () => { await first.result.current.markRead('a1', 'modal'); }); + first.unmount(); + __resetSessionModalFlag(); + + const second = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(second.result.current.isLoading).toBe(false)); + expect(second.result.current.autoOpenId).toBeNull(); + }); + + it('marks read optimistically and tells the server', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.markRead('a1', 'popover'); }); + expect(client.markRead).toHaveBeenCalledWith('a1', 'popover'); + expect(result.current.unreadCount).toBe(0); + expect(result.current.items[0].read).toBe(true); + }); + + it('keeps the optimistic read when the server call fails', async () => { + const client = makeClient({ markRead: vi.fn().mockRejectedValue(new Error('offline')) }); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.markRead('a1', 'popover'); }); + // A read that silently comes back is worse than one that is lost — the + // local mirror is what makes the dismissal stick. + expect(result.current.unreadCount).toBe(0); + }); + + it('marks everything read at once', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.markAllRead(); }); + expect(client.markAllRead).toHaveBeenCalled(); + expect(result.current.unreadCount).toBe(0); + }); + + it('persists the auto-open preference and reflects it immediately', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { await result.current.setAutoOpen(false); }); + expect(client.setPrefs).toHaveBeenCalledWith(false); + expect(result.current.prefs.autoOpenEnabled).toBe(false); + }); + + it('never surfaces a modal when the feed offers none', async () => { + const client = makeClient({ getFeed: vi.fn().mockResolvedValue(feed({ autoOpen: null })) }); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.autoOpenId).toBeNull(); + }); + + it('reports an error without throwing, so a portal never fails to render', async () => { + const client = makeClient({ getFeed: vi.fn().mockRejectedValue(new Error('500')) }); + const { result } = renderHook(() => useAnnouncements(client)); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeTruthy(); + expect(result.current.items).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('does nothing at all when disabled', async () => { + const client = makeClient(); + const { result } = renderHook(() => useAnnouncements(client, { enabled: false })); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(client.getFeed).not.toHaveBeenCalled(); + expect(result.current.items).toEqual([]); + }); +}); diff --git a/src/components/announcements/theme.ts b/src/components/announcements/theme.ts new file mode 100644 index 0000000..3a3a60d --- /dev/null +++ b/src/components/announcements/theme.ts @@ -0,0 +1,61 @@ +import type { AnnouncementPriority } from './types.js'; + +/** + * Alert red — the brand palette (src/styles/tokens.css) defines no alert + * colour, so `--announcement-alert` and `--announcement-alert-text` are + * declared there purely for this one case. `ALERT` gives this module one + * place to read the base tone for `accent`. + * + * Every class string below is written out as a literal, never built by + * interpolating a constant into a template (e.g. `` text-[${SOME_CONST}] ``): + * Tailwind's build-time scanner reads source text and does not execute code, + * so an interpolated class name is invisible to it and never gets a rule + * generated. That is the exact bug this map's counterpart in + * sphere-backoffice/src/lib/announcementTheme.ts shipped once already. + */ +const ALERT = 'var(--announcement-alert)'; + +export interface PriorityTheme { + label: string; + /** CSS colour value — a brand token where one exists, a literal only for the alert red. */ + accent: string; + pillClass: string; + /** Whether this priority interrupts the user with a modal. */ + opensModal: boolean; +} + +/** + * Priority is the announcement's theme, not a badge on it: the same value + * colours the pill, the row's accent and the CTA across every surface that + * reads it. Kept identical to sphere-backoffice's `announcementTheme.ts` so + * the admin composer's preview and the real thing on wallet/quest/dev-portal + * can never disagree. + * + * Orange means "needs your attention" product-wide, so `normal` deliberately + * gets a neutral tone rather than a dimmer orange — otherwise everything + * would end up orange and the signal would be lost. + */ +const THEMES: Record = { + critical: { + label: 'Critical', + accent: ALERT, + pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', + opensModal: true, + }, + major: { + label: 'Major', + accent: 'var(--accent)', + pillClass: 'bg-[rgba(255,111,0,0.14)] text-[var(--accent)]', + opensModal: true, + }, + normal: { + label: 'Normal', + accent: 'var(--text-secondary)', + pillClass: 'bg-white/6 text-white/62', + opensModal: false, + }, +}; + +export function priorityTheme(priority: AnnouncementPriority): PriorityTheme { + return THEMES[priority]; +} diff --git a/src/components/announcements/types.ts b/src/components/announcements/types.ts new file mode 100644 index 0000000..439c461 --- /dev/null +++ b/src/components/announcements/types.ts @@ -0,0 +1,40 @@ +export const ANNOUNCEMENT_CLIENT_IDS = ['sphere', 'quest', 'developer'] as const; +export type AnnouncementClientId = typeof ANNOUNCEMENT_CLIENT_IDS[number]; + +export type AnnouncementPriority = 'critical' | 'major' | 'normal'; +export type AnnouncementType = 'release' | 'update' | 'event' | 'maintenance' | 'security'; + +export interface ClientAnnouncement { + id: string; + priority: AnnouncementPriority; + type: AnnouncementType; + title: string; + summary: string; + body: string; + heroUrl: string | null; + /** Already flattened to this portal by the server. */ + cta: { label: string; url: string } | null; + publishAt: string; + expiresAt: string | null; + read: boolean; +} + +export interface AnnouncementFeed { + items: ClientAnnouncement[]; + unreadCount: number; + autoOpen: string | null; + prefs: { autoOpenEnabled: boolean }; +} + +/** + * The port each portal implements. This library never learns how any app + * authenticates — it is handed six functions and nothing else. + */ +export interface AnnouncementsClient { + getFeed(): Promise; + getArchive(cursor?: string): Promise<{ items: ClientAnnouncement[]; nextCursor: string | null }>; + markRead(id: string, via: 'modal' | 'popover'): Promise; + markAllRead(): Promise; + recordClick(id: string): Promise; + setPrefs(autoOpenEnabled: boolean): Promise; +} diff --git a/src/components/announcements/useAnnouncements.ts b/src/components/announcements/useAnnouncements.ts new file mode 100644 index 0000000..343ed07 --- /dev/null +++ b/src/components/announcements/useAnnouncements.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { AnnouncementsClient, AnnouncementFeed, ClientAnnouncement } from './types.js'; + +/** + * One modal per app session. Module-level rather than component state on + * purpose: a portal may mount the bell in more than one place, or remount it + * during navigation, and neither should earn the user a second interruption. + */ +let sessionModalShown = false; + +/** Test-only escape hatch — a module-level flag would otherwise leak between cases. */ +export function __resetSessionModalFlag(): void { + sessionModalShown = false; +} + +const DISMISSED_KEY = 'announcements:dismissed'; + +function readDismissed(): Set { + try { + const raw = localStorage.getItem(DISMISSED_KEY); + return new Set(raw ? (JSON.parse(raw) as string[]) : []); + } catch { + return new Set(); + } +} + +function rememberDismissed(id: string): void { + try { + const next = readDismissed(); + next.add(id); + // Bounded: the server is the real record, this only stops a flash on a + // cold start before the feed arrives. + localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next].slice(-100))); + } catch { + // A blocked or full localStorage must never break the portal. + } +} + +export interface UseAnnouncementsOptions { + /** Pass false while the portal has no identity yet — before a wallet exists. */ + enabled?: boolean; +} + +export function useAnnouncements( + client: AnnouncementsClient, + opts: UseAnnouncementsOptions = {}, +) { + const enabled = opts.enabled ?? true; + + const [feed, setFeed] = useState(null); + const [isLoading, setLoad] = useState(enabled); + const [error, setError] = useState(null); + const [autoOpenId, setAuto] = useState(null); + const alive = useRef(true); + + useEffect(() => () => { alive.current = false; }, []); + + const load = useCallback(async () => { + if (!enabled) { setLoad(false); return; } + try { + const next = await client.getFeed(); + if (!alive.current) return; + setFeed(next); + setError(null); + + const dismissed = readDismissed(); + const candidate = next.autoOpen; + if (candidate && !sessionModalShown && !dismissed.has(candidate)) { + sessionModalShown = true; + setAuto(candidate); + } + } catch (e) { + if (!alive.current) return; + // Announcements are never a reason a portal fails to render. + setError(e instanceof Error ? e : new Error(String(e))); + } finally { + if (alive.current) setLoad(false); + } + }, [client, enabled]); + + useEffect(() => { void load(); }, [load]); + + const applyRead = useCallback((ids: string[]) => { + setFeed(prev => { + if (!prev) return prev; + const set = new Set(ids); + const items: ClientAnnouncement[] = prev.items.map(i => (set.has(i.id) ? { ...i, read: true } : i)); + return { ...prev, items, unreadCount: items.filter(i => !i.read).length }; + }); + }, []); + + const markRead = useCallback(async (id: string, via: 'modal' | 'popover') => { + applyRead([id]); + rememberDismissed(id); + if (autoOpenId === id) setAuto(null); + try { + await client.markRead(id, via); + } catch { + // Deliberately swallowed: the optimistic state stands. A dismissal that + // silently comes back is worse than one the server has not recorded yet. + } + }, [applyRead, autoOpenId, client]); + + const markAllRead = useCallback(async () => { + setFeed(prev => prev && ({ ...prev, items: prev.items.map(i => ({ ...i, read: true })), unreadCount: 0 })); + try { await client.markAllRead(); } catch { /* as above */ } + }, [client]); + + const setAutoOpen = useCallback(async (value: boolean) => { + setFeed(prev => prev && ({ ...prev, prefs: { autoOpenEnabled: value } })); + try { await client.setPrefs(value); } catch { /* as above */ } + }, [client]); + + const dismissModal = useCallback(() => { setAuto(null); }, []); + + return { + items: feed?.items ?? [], + unreadCount: feed?.unreadCount ?? 0, + prefs: feed?.prefs ?? { autoOpenEnabled: true }, + autoOpenId, + isLoading, + error, + markRead, + markAllRead, + setAutoOpen, + dismissModal, + refresh: load, + }; +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 0b9008c..85d41cd 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -56,6 +56,17 @@ --radius-sm: 6px; --radius-md: 10px; --radius-lg: 14px; + + /* Alert red — the brand palette above defines no alert colour, so it lives + here as its own pair rather than being invented per-consumer. Used by the + announcements priority theme (src/components/announcements/theme.ts) for + "critical"; referenced from TypeScript as `var(--announcement-alert)` / + `var(--announcement-alert-text)` rather than a hex literal, so a + Tailwind class built from it (`text-[var(--announcement-alert-text)]`) + stays a literal string the build-time scanner can see. `-text` is a + lighter tint, for contrast on the tinted pill background. */ + --announcement-alert: #E5484D; + --announcement-alert-text: #FF8B8E; } /* ─── Tailwind 4 theme map ──────────────────────────────────────────────── From 7ad71ac42053259f3868908e06b1425db5ecc048 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 21:34:21 +0300 Subject: [PATCH 03/12] fix(announcements): restore borderClass, name the hook's return type, note single-instance rule - theme.ts: add borderClass back to PriorityTheme so Task 2's coloured left edge reads it from the same map instead of a second, independently maintained border-colour map. Critical references --announcement-alert directly instead of duplicating its rgb triplet; major/normal ported unchanged from sphere-backoffice/src/lib/announcementTheme.ts. - theme.test.ts: new test pinning all three priorities including borderClass, plus a source-text regression guard (mirrors backoffice's announcementTheme.test.ts) that no pillClass/borderClass line contains an unresolved interpolation artefact. - useAnnouncements.ts: export a named UseAnnouncementsResult return type instead of an inferred shape, and document at the top of the hook that it is meant to be called once per app with its values passed down, since the session-modal-once guarantee is module-level state. --- .../announcements/__tests__/theme.test.ts | 58 +++++++++++++++++ src/components/announcements/theme.ts | 62 +++++++++++-------- .../announcements/useAnnouncements.ts | 17 ++++- 3 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 src/components/announcements/__tests__/theme.test.ts diff --git a/src/components/announcements/__tests__/theme.test.ts b/src/components/announcements/__tests__/theme.test.ts new file mode 100644 index 0000000..4627df3 --- /dev/null +++ b/src/components/announcements/__tests__/theme.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { priorityTheme } from '../theme.js'; +// Raw text of this module's own source, for the source-scanning test below. +// `?raw` is a Vite import suffix that resolves to the file's literal text at +// transform time — used instead of node:fs/node:url because this repo has +// no @types/node installed for src/. Mirrors +// sphere-backoffice/src/lib/__tests__/announcementTheme.test.ts. +import themeSource from '../theme.ts?raw'; + +describe('priorityTheme', () => { + it('pins critical exactly, including the left-edge border', () => { + expect(priorityTheme('critical')).toEqual({ + label: 'Critical', + accent: 'var(--announcement-alert)', + pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', + borderClass: 'border-[var(--announcement-alert)]/28', + opensModal: true, + }); + }); + + it('pins major exactly, including the left-edge border', () => { + expect(priorityTheme('major')).toEqual({ + label: 'Major', + accent: 'var(--accent)', + pillClass: 'bg-[rgba(255,111,0,0.14)] text-[var(--accent)]', + borderClass: 'border-[rgba(255,111,0,0.28)]', + opensModal: true, + }); + }); + + it('pins normal exactly — no accent colour, per product rule', () => { + expect(priorityTheme('normal')).toEqual({ + label: 'Normal', + accent: 'var(--text-secondary)', + pillClass: 'bg-white/6 text-white/62', + borderClass: 'border-[var(--border)]', + opensModal: false, + }); + }); + + // Regression guard, mirrors sphere-backoffice's announcementTheme.test.ts: + // a template literal that interpolates a constant into a Tailwind bracket + // class (`` `border-[${ALERT}]/28` ``) evaluates to a normal-looking string + // at runtime — the pins above can't tell it apart from a literal — but + // Tailwind's build-time scanner only reads the *source text*, so the + // interpolated form never gets a rule and the border silently renders with + // no colour. This reads the source text itself, the same thing the scanner + // reads. + it('never builds pillClass or borderClass by interpolation — Tailwind cannot scan the result', () => { + const classLines = themeSource + .split('\n') + .filter((line: string) => line.includes('pillClass:') || line.includes('borderClass:')); + expect(classLines.length).toBeGreaterThan(0); + for (const line of classLines) { + expect(line).not.toContain('${'); + } + }); +}); diff --git a/src/components/announcements/theme.ts b/src/components/announcements/theme.ts index 3a3a60d..f097a96 100644 --- a/src/components/announcements/theme.ts +++ b/src/components/announcements/theme.ts @@ -6,30 +6,33 @@ import type { AnnouncementPriority } from './types.js'; * declared there purely for this one case. `ALERT` gives this module one * place to read the base tone for `accent`. * - * Every class string below is written out as a literal, never built by - * interpolating a constant into a template (e.g. `` text-[${SOME_CONST}] ``): - * Tailwind's build-time scanner reads source text and does not execute code, - * so an interpolated class name is invisible to it and never gets a rule - * generated. That is the exact bug this map's counterpart in - * sphere-backoffice/src/lib/announcementTheme.ts shipped once already. + * Every class string below — `pillClass` and `borderClass` alike — is + * written out as a literal, never built by interpolating a constant into a + * template (e.g. `` text-[${SOME_CONST}] ``): Tailwind's build-time scanner + * reads source text and does not execute code, so an interpolated class name + * is invisible to it and never gets a rule generated. That is the exact bug + * this map's counterpart in sphere-backoffice/src/lib/announcementTheme.ts + * shipped once already. */ const ALERT = 'var(--announcement-alert)'; export interface PriorityTheme { - label: string; + label: string; /** CSS colour value — a brand token where one exists, a literal only for the alert red. */ - accent: string; - pillClass: string; + accent: string; + pillClass: string; + /** Coloured left edge — the same theme read by the pill and the CTA, so a bell/modal border can't drift from them. */ + borderClass: string; /** Whether this priority interrupts the user with a modal. */ - opensModal: boolean; + opensModal: boolean; } /** * Priority is the announcement's theme, not a badge on it: the same value - * colours the pill, the row's accent and the CTA across every surface that - * reads it. Kept identical to sphere-backoffice's `announcementTheme.ts` so - * the admin composer's preview and the real thing on wallet/quest/dev-portal - * can never disagree. + * colours the pill, the row's left edge and the CTA across every surface + * that reads it. Kept identical to sphere-backoffice's `announcementTheme.ts` + * so the admin composer's preview and the real thing on wallet/quest/ + * dev-portal can never disagree. * * Orange means "needs your attention" product-wide, so `normal` deliberately * gets a neutral tone rather than a dimmer orange — otherwise everything @@ -37,22 +40,29 @@ export interface PriorityTheme { */ const THEMES: Record = { critical: { - label: 'Critical', - accent: ALERT, - pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', - opensModal: true, + label: 'Critical', + accent: ALERT, + pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', + // References the token directly (with a Tailwind v4 opacity modifier) + // rather than a second raw rgba literal, so the one place this colour is + // defined stays --announcement-alert in tokens.css, not that plus a + // duplicated (229,72,77) triplet here. + borderClass: 'border-[var(--announcement-alert)]/28', + opensModal: true, }, major: { - label: 'Major', - accent: 'var(--accent)', - pillClass: 'bg-[rgba(255,111,0,0.14)] text-[var(--accent)]', - opensModal: true, + label: 'Major', + accent: 'var(--accent)', + pillClass: 'bg-[rgba(255,111,0,0.14)] text-[var(--accent)]', + borderClass: 'border-[rgba(255,111,0,0.28)]', + opensModal: true, }, normal: { - label: 'Normal', - accent: 'var(--text-secondary)', - pillClass: 'bg-white/6 text-white/62', - opensModal: false, + label: 'Normal', + accent: 'var(--text-secondary)', + pillClass: 'bg-white/6 text-white/62', + borderClass: 'border-[var(--border)]', + opensModal: false, }, }; diff --git a/src/components/announcements/useAnnouncements.ts b/src/components/announcements/useAnnouncements.ts index 343ed07..533be7a 100644 --- a/src/components/announcements/useAnnouncements.ts +++ b/src/components/announcements/useAnnouncements.ts @@ -41,10 +41,25 @@ export interface UseAnnouncementsOptions { enabled?: boolean; } +export interface UseAnnouncementsResult { + items: ClientAnnouncement[]; + unreadCount: number; + prefs: { autoOpenEnabled: boolean }; + autoOpenId: string | null; + isLoading: boolean; + error: Error | null; + markRead: (id: string, via: 'modal' | 'popover') => Promise; + markAllRead: () => Promise; + setAutoOpen: (value: boolean) => Promise; + dismissModal: () => void; + refresh: () => Promise; +} + +/** Call this hook once per app and pass its values down to the bell and the modal — see `sessionModalShown` above for why. */ export function useAnnouncements( client: AnnouncementsClient, opts: UseAnnouncementsOptions = {}, -) { +): UseAnnouncementsResult { const enabled = opts.enabled ?? true; const [feed, setFeed] = useState(null); From 5b981d3af6e773a72bce7313e3352c22d36ceabe Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 21:37:01 +0300 Subject: [PATCH 04/12] fix(announcements): bake critical border alpha into a token instead of an opacity modifier Replace border-[var(--announcement-alert)]/28 with a plain literal border-[var(--announcement-alert-border)], backed by a new tokens.css custom property that bakes in the 0.28 alpha directly. Removes reliance on Tailwind's opacity modifier applied to a var() arbitrary value, a form with no precedent elsewhere in this codebase and no guarantee of support across every consumer's Tailwind version. major/normal already used no modifier and needed no change. theme.test.ts now also asserts no borderClass carries an opacity modifier. --- .../announcements/__tests__/theme.test.ts | 14 +++++++++++++- src/components/announcements/theme.ts | 12 +++++++----- src/styles/tokens.css | 13 ++++++++++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/components/announcements/__tests__/theme.test.ts b/src/components/announcements/__tests__/theme.test.ts index 4627df3..a684075 100644 --- a/src/components/announcements/__tests__/theme.test.ts +++ b/src/components/announcements/__tests__/theme.test.ts @@ -13,7 +13,7 @@ describe('priorityTheme', () => { label: 'Critical', accent: 'var(--announcement-alert)', pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', - borderClass: 'border-[var(--announcement-alert)]/28', + borderClass: 'border-[var(--announcement-alert-border)]', opensModal: true, }); }); @@ -55,4 +55,16 @@ describe('priorityTheme', () => { expect(line).not.toContain('${'); } }); + + // Regression guard: an opacity modifier applied to a `var()` arbitrary + // value (`border-[var(--x)]/28`) has no precedent in this codebase and its + // support isn't guaranteed across every consumer's Tailwind version — + // colours with alpha belong baked into the token (`--announcement-alert-border` + // in tokens.css) instead. This pins that choice so nobody reintroduces the + // modifier form for a "cleaner" one-liner later. + it('never applies an opacity modifier to a borderClass value', () => { + for (const priority of ['critical', 'major', 'normal'] as const) { + expect(priorityTheme(priority).borderClass).not.toMatch(/\]\/\d/); + } + }); }); diff --git a/src/components/announcements/theme.ts b/src/components/announcements/theme.ts index f097a96..4589121 100644 --- a/src/components/announcements/theme.ts +++ b/src/components/announcements/theme.ts @@ -43,11 +43,13 @@ const THEMES: Record = { label: 'Critical', accent: ALERT, pillClass: 'bg-[rgba(229,72,77,0.15)] text-[var(--announcement-alert-text)]', - // References the token directly (with a Tailwind v4 opacity modifier) - // rather than a second raw rgba literal, so the one place this colour is - // defined stays --announcement-alert in tokens.css, not that plus a - // duplicated (229,72,77) triplet here. - borderClass: 'border-[var(--announcement-alert)]/28', + // --announcement-alert-border bakes the 0.28 alpha into the token itself + // (see tokens.css), rather than applying a Tailwind opacity modifier to + // a `var()` arbitrary value here — that form has no precedent in either + // repo and isn't worth trusting sight-unseen after this plan already + // lost a day to a class that looked fine in source and was never + // emitted. No modifier, no question. + borderClass: 'border-[var(--announcement-alert-border)]', opensModal: true, }, major: { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 85d41cd..9620980 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -64,9 +64,16 @@ `var(--announcement-alert-text)` rather than a hex literal, so a Tailwind class built from it (`text-[var(--announcement-alert-text)]`) stays a literal string the build-time scanner can see. `-text` is a - lighter tint, for contrast on the tinted pill background. */ - --announcement-alert: #E5484D; - --announcement-alert-text: #FF8B8E; + lighter tint, for contrast on the tinted pill background. `-border` bakes + in the alpha (same rgba(229,72,77,0.28) value sphere-backoffice uses for + its own critical border) rather than relying on a Tailwind opacity + modifier applied to a `var()` arbitrary value — that modifier form has no + precedent elsewhere in either repo, and this plan already lost a day to + a class that looked fine in source and was never emitted; baking the + alpha into the token removes the question instead of trusting it. */ + --announcement-alert: #E5484D; + --announcement-alert-text: #FF8B8E; + --announcement-alert-border: rgba(229, 72, 77, 0.28); } /* ─── Tailwind 4 theme map ──────────────────────────────────────────────── From b898a3089afe504452c239c0993e8359fe73256e Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 21:49:46 +0300 Subject: [PATCH 05/12] feat(announcements): add the bell and its popover --- package-lock.json | 18 ++ package.json | 1 + .../announcements/AnnouncementBell.tsx | 162 ++++++++++++++++++ .../announcements/AnnouncementRow.tsx | 107 ++++++++++++ .../__tests__/AnnouncementBell.test.tsx | 88 ++++++++++ 5 files changed, 376 insertions(+) create mode 100644 src/components/announcements/AnnouncementBell.tsx create mode 100644 src/components/announcements/AnnouncementRow.tsx create mode 100644 src/components/announcements/__tests__/AnnouncementBell.test.tsx diff --git a/package-lock.json b/package-lock.json index e1cc3d8..86701ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.7.0", @@ -1583,6 +1584,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3444,6 +3459,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3453,6 +3469,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -3702,6 +3719,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/semver": { diff --git a/package.json b/package.json index c20ea86..e6656c1 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.7.0", diff --git a/src/components/announcements/AnnouncementBell.tsx b/src/components/announcements/AnnouncementBell.tsx new file mode 100644 index 0000000..e67ed64 --- /dev/null +++ b/src/components/announcements/AnnouncementBell.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react'; +import { Bell } from 'lucide-react'; +import type { ClientAnnouncement } from './types.js'; +import { AnnouncementRow } from './AnnouncementRow.js'; + +export interface AnnouncementBellProps { + items: ClientAnnouncement[]; + unreadCount: number; + prefs: { autoOpenEnabled: boolean }; + onMarkRead: (id: string, via: 'modal' | 'popover') => void; + onMarkAllRead: () => void; + onSetAutoOpen: (value: boolean) => void; + onOpenItem: (announcement: ClientAnnouncement) => void; + /** Footer link to the full announcement centre. Footer is omitted when absent. */ + onViewAll?: () => void; +} + +/** + * The bell that sits in every portal's header, and the popover behind it. + * + * Purely prop-driven — this never calls `useAnnouncements` itself. That hook + * keeps a module-level "one modal per session" flag built for exactly one + * call per app; a header component that called it directly would risk a + * second call (a remount on navigation, a second header on a wider layout) + * silently breaking that invariant. Every value here — including the two + * write actions the toggle needs — arrives through props instead, so the + * host app owns the single hook call and this component just renders it. + * + * The popover always holds every announcement, read or not: it is a mailbox, + * not a notification tray that empties itself. The auto-open toggle only + * governs whether critical/major announcements interrupt the user with a + * modal (Task 3) on load — it never hides anything from this list. + */ +export function AnnouncementBell({ + items, unreadCount, prefs, onMarkRead, onMarkAllRead, onSetAutoOpen, onOpenItem, onViewAll, +}: AnnouncementBellProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // Escape and an outside click both close the popover — standard dropdown + // behaviour, listened for only while it's actually open. + useEffect(() => { + if (!open) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + const onPointerDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('mousedown', onPointerDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onPointerDown); + }; + }, [open]); + + const handleRowClick = (announcement: ClientAnnouncement) => { + // One gesture: opening a row and dismissing its unread state are the + // same click, not two. + onOpenItem(announcement); + onMarkRead(announcement.id, 'popover'); + }; + + const badge = unreadCount > 0 ? (unreadCount > 99 ? '99+' : String(unreadCount)) : null; + const bellLabel = badge ? `Announcements, ${unreadCount} unread` : 'Announcements'; + + return ( +
+ + + {open && ( +
+
+ {/* Anton only reads cleanly at 16px+ — 1.05rem clears that, matching FormModal's header treatment. */} + + Announcements + + +
+ +
+ {items.length === 0 ? ( +
+ Nothing new here yet. +
+ ) : ( + items.map(item => ( + + )) + )} +
+ +
+ + Auto-open important announcements + + +
+ + {onViewAll && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/announcements/AnnouncementRow.tsx b/src/components/announcements/AnnouncementRow.tsx new file mode 100644 index 0000000..979f628 --- /dev/null +++ b/src/components/announcements/AnnouncementRow.tsx @@ -0,0 +1,107 @@ +import { Calendar, RefreshCw, Rocket, ShieldAlert, Wrench } from 'lucide-react'; +import type { AnnouncementType, ClientAnnouncement } from './types.js'; +import { priorityTheme } from './theme.js'; + +export interface AnnouncementRowProps { + announcement: ClientAnnouncement; + /** Fires from any part of the row — opening and marking read are one gesture, not two. */ + onClick: (announcement: ClientAnnouncement) => void; +} + +/** + * One glyph per announcement type, stood in for the hero thumbnail when + * there is no image — an attachment-less note must never look like a + * broken image box. Reuses lucide-react (already a dependency here, see + * DashboardLayout/KPICard) instead of hand-drawn paths. + */ +const TYPE_ICONS: Record = { + release: Rocket, + update: RefreshCw, + event: Calendar, + maintenance: Wrench, + security: ShieldAlert, +}; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; +const MONTH = 30 * DAY; + +/** Coarse "time ago" — good enough for a mailbox row, not a precise countdown. */ +function formatRelativeTime(iso: string): string { + const elapsed = Date.now() - new Date(iso).getTime(); + if (elapsed < MINUTE) return 'Just now'; + if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`; + if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`; + if (elapsed < WEEK) return `${Math.floor(elapsed / DAY)}d ago`; + if (elapsed < MONTH) return `${Math.floor(elapsed / WEEK)}w ago`; + return `${Math.floor(elapsed / MONTH)}mo ago`; +} + +/** + * One row in the bell's popover (and, later, the announcement centre). + * Priority is read entirely from `priorityTheme()` (Task 1) for the pill and + * the left edge — this file owns no colour map of its own. Read state is + * this row's own concern: an unread row gets an accent dot and a tinted + * background; the coloured left edge comes from the theme for every + * priority, but only reads as an accent for critical/major since `normal`'s + * `borderClass` is the same neutral token as the page border. + * + * The whole row is a single button: clicking anywhere on it both opens the + * announcement and marks it read, since a mailbox row is scanned and acted + * on in one gesture, not two. + */ +export function AnnouncementRow({ announcement, onClick }: AnnouncementRowProps) { + const theme = priorityTheme(announcement.priority); + const TypeIcon = TYPE_ICONS[announcement.type]; + + return ( + + ); +} diff --git a/src/components/announcements/__tests__/AnnouncementBell.test.tsx b/src/components/announcements/__tests__/AnnouncementBell.test.tsx new file mode 100644 index 0000000..fc804a5 --- /dev/null +++ b/src/components/announcements/__tests__/AnnouncementBell.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AnnouncementBell } from '../AnnouncementBell.js'; +import type { ClientAnnouncement } from '../types.js'; + +function item(over: Partial = {}): ClientAnnouncement { + return { + id: 'a1', priority: 'major', type: 'release', title: 'Season 3 quests are open', + summary: 'Forty-two new quests went live.', body: 'Body', heroUrl: null, cta: null, + publishAt: '2026-07-01T00:00:00.000Z', expiresAt: null, read: false, ...over, + }; +} + +const noop = () => {}; +function props(over: Record = {}) { + return { + items: [item()], unreadCount: 1, prefs: { autoOpenEnabled: true }, + onMarkRead: noop, onMarkAllRead: noop, onSetAutoOpen: noop, onOpenItem: noop, + ...over, + }; +} + +describe('AnnouncementBell', () => { + it('shows the unread count on the bell', () => { + render(); + expect(screen.getByRole('button', { name: /announcements/i }).textContent).toContain('1'); + }); + + it('hides the badge entirely when nothing is unread', () => { + render(); + expect(screen.getByRole('button', { name: /announcements/i }).textContent).not.toContain('0'); + }); + + it('keeps the popover closed until the bell is clicked', async () => { + render(); + expect(screen.queryByText('Season 3 quests are open')).toBeNull(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByText('Season 3 quests are open')).toBeTruthy(); + }); + + it('opens an announcement and marks it read in one click', async () => { + const onOpenItem = vi.fn(); + const onMarkRead = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.click(screen.getByText('Season 3 quests are open')); + expect(onOpenItem).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' })); + expect(onMarkRead).toHaveBeenCalledWith('a1', 'popover'); + }); + + it('marks everything read from the footer', async () => { + const onMarkAllRead = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.click(screen.getByRole('button', { name: /mark all read/i })); + expect(onMarkAllRead).toHaveBeenCalled(); + }); + + it('exposes the auto-open toggle and reports changes', async () => { + const onSetAutoOpen = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + const toggle = screen.getByRole('switch', { name: /auto-open/i }); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + await userEvent.click(toggle); + expect(onSetAutoOpen).toHaveBeenCalledWith(false); + }); + + it('renders an empty state rather than a bare panel', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByText(/nothing new/i)).toBeTruthy(); + }); + + it('falls back to a type icon when an announcement has no image', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + expect(screen.getByTestId('announcement-type-icon')).toBeTruthy(); + }); + + it('closes on Escape', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByText('Season 3 quests are open')).toBeNull(); + }); +}); From 7bc65b5f71f0cb59762d2557e90ae8e79d222f9c Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 22:02:23 +0300 Subject: [PATCH 06/12] feat(announcements): add the modal and markdown rendering --- package-lock.json | 1550 ++++++++++++++++- package.json | 4 +- .../announcements/AnnouncementModal.tsx | 175 ++ src/components/announcements/Markdown.tsx | 61 + .../__tests__/AnnouncementModal.test.tsx | 60 + src/components/announcements/index.ts | 13 + src/index.ts | 3 + 7 files changed, 1818 insertions(+), 48 deletions(-) create mode 100644 src/components/announcements/AnnouncementModal.tsx create mode 100644 src/components/announcements/Markdown.tsx create mode 100644 src/components/announcements/__tests__/AnnouncementModal.test.tsx create mode 100644 src/components/announcements/index.ts diff --git a/package-lock.json b/package-lock.json index 86701ca..7117f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "framer-motion": "^11.18.2", "lucide-react": ">=0.400.0", "react-dropzone": "^14.4.1", - "react-easy-crop": "^6.2.2" + "react-easy-crop": "^6.2.2", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@dnd-kit/core": "^6.0.0", @@ -1722,11 +1724,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/react": { @@ -1749,6 +1792,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1756,6 +1805,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1994,6 +2049,16 @@ "node": ">=4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -2102,6 +2167,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -2119,6 +2194,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -2168,6 +2283,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2387,7 +2512,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2415,6 +2539,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2439,12 +2576,24 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2606,6 +2755,28 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2633,6 +2804,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2850,6 +3027,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2863,6 +3080,16 @@ "node": ">=18" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2925,6 +3152,12 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2935,6 +3168,62 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3056,6 +3345,16 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3069,63 +3368,906 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.400.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", - "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3193,7 +4335,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3260,6 +4401,31 @@ "node": ">=0.10.0" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3445,6 +4611,16 @@ "dev": true, "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3516,6 +4692,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -3626,6 +4829,72 @@ "redux": "^5.0.0" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -3759,6 +5028,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3773,6 +5052,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3786,6 +5079,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3963,6 +5274,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4051,6 +5382,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4092,6 +5510,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -4831,6 +6277,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index e6656c1..6a82930 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "framer-motion": "^11.18.2", "lucide-react": ">=0.400.0", "react-dropzone": "^14.4.1", - "react-easy-crop": "^6.2.2" + "react-easy-crop": "^6.2.2", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "peerDependencies": { "@dnd-kit/core": "^6.0.0", diff --git a/src/components/announcements/AnnouncementModal.tsx b/src/components/announcements/AnnouncementModal.tsx new file mode 100644 index 0000000..50a0643 --- /dev/null +++ b/src/components/announcements/AnnouncementModal.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef } from 'react'; +import { TriangleAlert } from 'lucide-react'; +import type { ClientAnnouncement } from './types.js'; +import { priorityTheme } from './theme.js'; +import { Markdown } from './Markdown.js'; + +export interface AnnouncementModalProps { + announcement: ClientAnnouncement; + onDismiss: () => void; + onCtaClick: (announcement: ClientAnnouncement) => void; +} + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * The one surface in this library that interrupts a user rather than waiting + * to be noticed. Two flavours, chosen by priority, not by a prop the host + * could get wrong: + * + * - Editorial (major/normal): hero image, an Anton headline (22px+, the only + * size this library lets Anton run below its usual dashboard-heading size), + * the full markdown body, an accent CTA, "Later" as the quiet way out. + * - Alert (critical): no image — a screenshot of a release is exciting, a + * screenshot of an outage is not, and the image would cost a beat of + * reading time an incident notice can't spend — a warning glyph, a short + * body, an alert-red CTA, "Got it". + * + * Both trap focus while open and hand it back to whatever had it before the + * modal opened, on the theory that an announcement interrupts the page but + * must not lose the user's place on it. + */ +export function AnnouncementModal({ announcement, onDismiss, onCtaClick }: AnnouncementModalProps) { + const theme = priorityTheme(announcement.priority); + const isAlert = announcement.priority === 'critical'; + + const dialogRef = useRef(null); + const returnFocusTo = useRef(null); + + useEffect(() => { + returnFocusTo.current = document.activeElement as HTMLElement | null; + + const dialog = dialogRef.current; + const focusables = dialog?.querySelectorAll(FOCUSABLE_SELECTOR); + (focusables?.[0] ?? dialog)?.focus(); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onDismiss(); + return; + } + if (e.key !== 'Tab' || !dialog) return; + + const nodes = Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)); + if (nodes.length === 0) { + e.preventDefault(); + return; + } + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const current = document.activeElement; + + if (e.shiftKey) { + if (current === first || !dialog.contains(current)) { + e.preventDefault(); + last.focus(); + } + } else if (current === last || !dialog.contains(current)) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + returnFocusTo.current?.focus?.(); + }; + }, [onDismiss]); + + const handleCta = () => { + onCtaClick(announcement); + onDismiss(); + }; + + return ( +
+
+ {!isAlert && announcement.heroUrl && ( + // A non-empty alt is required here, not just nice-to-have: an empty + // alt gives an the "presentation" accessibility role instead + // of "img", which is also why an empty alt would fail this + // component's own hero-image test. + {announcement.title} + )} + +
+ {isAlert ? ( +
+ + + {theme.label} + +
+ ) : ( + + {theme.label} + + )} + + {isAlert ? ( +

+ {announcement.title} +

+ ) : ( + // Anton reads cleanly at 16px+ only; this hero headline sits well + // above that floor, unlike the smaller UI labels elsewhere that + // stay on the body font instead. +

+ {announcement.title} +

+ )} + + {isAlert ? ( +

+ {announcement.summary} +

+ ) : ( +
+ {announcement.body} +
+ )} + +
+ + {announcement.cta && ( + + )} +
+
+
+
+ ); +} diff --git a/src/components/announcements/Markdown.tsx b/src/components/announcements/Markdown.tsx new file mode 100644 index 0000000..b2c9946 --- /dev/null +++ b/src/components/announcements/Markdown.tsx @@ -0,0 +1,61 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { ComponentPropsWithoutRef } from 'react'; + +export interface MarkdownProps { + children: string; +} + +/** + * The one place in this library that renders announcement body text. Deliberately + * carries no `rehype-raw` and no `dangerouslySetInnerHTML` — react-markdown parses + * the body into a syntax tree and renders each node as a real React element, so an + * `` written into the body is emitted as inert text, never as an + * element the DOM would execute. That is the whole security model here: there is + * nothing to sanitise because raw HTML is never turned into elements in the first + * place. Do not add `rehype-raw` to this file. + */ +export function Markdown({ children }: MarkdownProps) { + return ( +
+

, + h2: props =>

, + h3: props =>

, + p: props =>

, + ul: props =>

    , + ol: props =>
      , + li: props =>
    1. , + strong: props => , + code: props => ( + + ), + a: ({ href, ...props }: ComponentPropsWithoutRef<'a'>) => { + // Absolute links open in a new tab so an announcement can never + // navigate the host app away from itself; relative in-app links + // (e.g. a quest deep link) stay in place. + const isAbsolute = !!href && /^[a-z][a-z0-9+.-]*:\/\//i.test(href); + return ( + + ); + }, + }} + > + {children} + +

+ ); +} diff --git a/src/components/announcements/__tests__/AnnouncementModal.test.tsx b/src/components/announcements/__tests__/AnnouncementModal.test.tsx new file mode 100644 index 0000000..cb7a052 --- /dev/null +++ b/src/components/announcements/__tests__/AnnouncementModal.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AnnouncementModal } from '../AnnouncementModal.js'; +import type { ClientAnnouncement } from '../types.js'; + +function item(over: Partial = {}): ClientAnnouncement { + return { + id: 'a1', priority: 'major', type: 'release', title: 'Season 3 quests are open', + summary: 'S', body: 'Forty-two **new** quests.', heroUrl: 'https://cdn.example/hero.png', + cta: { label: 'Browse quests', url: '/quests' }, + publishAt: '2026-07-01T00:00:00.000Z', expiresAt: null, read: false, ...over, + }; +} + +describe('AnnouncementModal', () => { + it('renders markdown rather than its syntax', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.getByText('new').tagName).toBe('STRONG'); + expect(screen.queryByText(/\*\*new\*\*/)).toBeNull(); + }); + + it('shows the hero image for an editorial announcement', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.getByRole('img').getAttribute('src')).toBe('https://cdn.example/hero.png'); + }); + + it('hides the hero image for a critical one, which must be read first', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('reports a cta click and dismisses', async () => { + const onCtaClick = vi.fn(); + const onDismiss = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /browse quests/i })); + expect(onCtaClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' })); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('offers only a dismiss action when there is no cta', () => { + render( {}} onCtaClick={() => {}} />); + expect(screen.getByRole('button', { name: /got it/i })).toBeTruthy(); + }); + + it('dismisses on Escape', async () => { + const onDismiss = vi.fn(); + render( {}} />); + await userEvent.keyboard('{Escape}'); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('never renders raw html from the body', () => { + const evil = item({ body: 'before after' }); + const { container } = render( {}} onCtaClick={() => {}} />); + expect(container.querySelector('img[onerror]')).toBeNull(); + expect(container.textContent).toContain('after'); + }); +}); diff --git a/src/components/announcements/index.ts b/src/components/announcements/index.ts new file mode 100644 index 0000000..c32dda7 --- /dev/null +++ b/src/components/announcements/index.ts @@ -0,0 +1,13 @@ +// Announcements — types, port, theme, hook and the surfaces built on them. +export * from './types.js'; +export * from './theme.js'; +export { useAnnouncements, __resetSessionModalFlag } from './useAnnouncements.js'; +export type { UseAnnouncementsOptions, UseAnnouncementsResult } from './useAnnouncements.js'; +export { AnnouncementBell } from './AnnouncementBell.js'; +export type { AnnouncementBellProps } from './AnnouncementBell.js'; +export { AnnouncementRow } from './AnnouncementRow.js'; +export type { AnnouncementRowProps } from './AnnouncementRow.js'; +export { Markdown } from './Markdown.js'; +export type { MarkdownProps } from './Markdown.js'; +export { AnnouncementModal } from './AnnouncementModal.js'; +export type { AnnouncementModalProps } from './AnnouncementModal.js'; diff --git a/src/index.ts b/src/index.ts index 7cc9174..07d3e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,5 +36,8 @@ export * from './components/Icons'; // Media components (uploader, gallery, marketplace preview) export * from './components/media/index.js'; +// Announcements (types, port, theme, hook, bell/popover, modal, markdown) +export * from './components/announcements/index.js'; + // Types export * from './types'; From 9afa719f4972ba677715265e4e1c0199ce579023 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 22:18:00 +0300 Subject: [PATCH 07/12] fix(announcements): stop the modal's focus trap re-running per parent render --- .../announcements/AnnouncementModal.tsx | 34 ++++++- .../__tests__/AnnouncementModal.test.tsx | 91 ++++++++++++++++++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/components/announcements/AnnouncementModal.tsx b/src/components/announcements/AnnouncementModal.tsx index 50a0643..1bcb372 100644 --- a/src/components/announcements/AnnouncementModal.tsx +++ b/src/components/announcements/AnnouncementModal.tsx @@ -37,6 +37,22 @@ export function AnnouncementModal({ announcement, onDismiss, onCtaClick }: Annou const dialogRef = useRef(null); const returnFocusTo = useRef(null); + // `onDismiss` is read through a ref, not captured by the effect below. + // Consumers overwhelmingly pass an inline arrow — that's the pattern + // Tasks 4/5 use — which gets a new identity on every parent render. If + // that identity were in the focus effect's dependency array, the whole + // effect would tear down and re-run on every parent re-render while the + // modal is open: cleanup yanks focus back to the pre-open element, then + // setup immediately re-steals it into the dialog. Visible flicker and a + // repeat screen-reader announcement, in the one component whose job is to + // interrupt people. Keeping the callback in a ref means the effect below + // never needs it in its dependency array, so its lifetime is the modal's + // lifetime, not the callback's identity. + const onDismissRef = useRef(onDismiss); + useEffect(() => { + onDismissRef.current = onDismiss; + }, [onDismiss]); + useEffect(() => { returnFocusTo.current = document.activeElement as HTMLElement | null; @@ -46,7 +62,7 @@ export function AnnouncementModal({ announcement, onDismiss, onCtaClick }: Annou const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - onDismiss(); + onDismissRef.current(); return; } if (e.key !== 'Tab' || !dialog) return; @@ -76,7 +92,11 @@ export function AnnouncementModal({ announcement, onDismiss, onCtaClick }: Annou document.removeEventListener('keydown', onKeyDown); returnFocusTo.current?.focus?.(); }; - }, [onDismiss]); + // Deliberately empty: this effect's lifetime is the modal's mount + // lifetime. It must run exactly once on mount and clean up exactly once + // on unmount — see the comment above `onDismissRef`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const handleCta = () => { onCtaClick(announcement); @@ -155,7 +175,15 @@ export function AnnouncementModal({ announcement, onDismiss, onCtaClick }: Annou className="text-sm font-medium px-4 py-2 rounded-lg transition-colors hover:bg-white/5" style={{ color: 'var(--text-secondary)' }} > - {announcement.cta ? 'Later' : 'Got it'} + {/* Flavour decides first, CTA presence second — not the other + way around. Alert is "do something now": the acknowledgement + is always "Got it", whether or not there's also a CTA to act + on (a critical announcement with a CTA must not say "Later" + to something urgent). Editorial is "look what we shipped": + "Later" only makes sense when there's something to defer, + i.e. a CTA — with none, this is the sole action and reads as + "Got it" too. */} + {isAlert ? 'Got it' : (announcement.cta ? 'Later' : 'Got it')} {announcement.cta && ( + {/* A fresh arrow every render, on purpose: this is the shape a + consumer almost always writes, not a contrived worst case. */} + {}} onCtaClick={() => {}} /> + {tick} + + ); + } + + render(); + const cta = screen.getByRole('button', { name: /browse quests/i }); + cta.focus(); + expect(document.activeElement).toBe(cta); + + // `fireEvent.click`, not `userEvent.click`: userEvent's click also + // focuses the clicked element as part of simulating a real user + // interaction, which would move focus to the "bump" button itself and + // defeat the point of this test. `fireEvent` only dispatches the click + // event and runs the handler, isolating the re-render this test cares + // about from an unrelated focus change. + fireEvent.click(screen.getByRole('button', { name: /^bump$/i })); + expect(screen.getByTestId('tick').textContent).toBe('1'); + + // Focus must still be exactly where the user left it. + expect(document.activeElement).toBe(cta); + }); + }); }); From 06f0de72e2beabf31d9cbe0863d49e81e3726e85 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Mon, 3 Aug 2026 23:31:27 +0300 Subject: [PATCH 08/12] fix(announcements): let the bell's popover hang from either edge --- .../announcements/AnnouncementBell.tsx | 15 +++++++++++++-- .../__tests__/AnnouncementBell.test.tsx | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/announcements/AnnouncementBell.tsx b/src/components/announcements/AnnouncementBell.tsx index e67ed64..5ce133b 100644 --- a/src/components/announcements/AnnouncementBell.tsx +++ b/src/components/announcements/AnnouncementBell.tsx @@ -13,6 +13,17 @@ export interface AnnouncementBellProps { onOpenItem: (announcement: ClientAnnouncement) => void; /** Footer link to the full announcement centre. Footer is omitted when absent. */ onViewAll?: () => void; + /** + * Which edge the popover hangs from. Defaults to 'right' (today's + * behaviour, the bell's own right edge). Pass 'left' when the bell sits + * near the left edge of a narrow container (e.g. a sidebar) and a + * right-anchored `w-80` popover would render partly or entirely + * off-screen. Both `left-0`/`right-0` below are complete literal class + * names selected by a ternary, never built by interpolation — this + * library ships class STRINGS the consumer's Tailwind build compiles, and + * a scanner can only see whole names present verbatim in source. + */ + align?: 'left' | 'right'; } /** @@ -32,7 +43,7 @@ export interface AnnouncementBellProps { * modal (Task 3) on load — it never hides anything from this list. */ export function AnnouncementBell({ - items, unreadCount, prefs, onMarkRead, onMarkAllRead, onSetAutoOpen, onOpenItem, onViewAll, + items, unreadCount, prefs, onMarkRead, onMarkAllRead, onSetAutoOpen, onOpenItem, onViewAll, align = 'right', }: AnnouncementBellProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -90,7 +101,7 @@ export function AnnouncementBell({ {open && (
diff --git a/src/components/announcements/__tests__/AnnouncementBell.test.tsx b/src/components/announcements/__tests__/AnnouncementBell.test.tsx index fc804a5..9724895 100644 --- a/src/components/announcements/__tests__/AnnouncementBell.test.tsx +++ b/src/components/announcements/__tests__/AnnouncementBell.test.tsx @@ -85,4 +85,20 @@ describe('AnnouncementBell', () => { await userEvent.keyboard('{Escape}'); expect(screen.queryByText('Season 3 quests are open')).toBeNull(); }); + + it('hangs the popover from its right edge by default', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + const popover = screen.getByText('Season 3 quests are open').closest('[class*="absolute"]'); + expect(popover?.className).toContain('right-0'); + expect(popover?.className).not.toContain('left-0'); + }); + + it('hangs the popover from its left edge when align="left"', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /announcements/i })); + const popover = screen.getByText('Season 3 quests are open').closest('[class*="absolute"]'); + expect(popover?.className).toContain('left-0'); + expect(popover?.className).not.toContain('right-0'); + }); }); From ab5e893050e641259dabb85c9af05344fbd00721 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Tue, 4 Aug 2026 01:48:01 +0300 Subject: [PATCH 09/12] fix(announcements): StrictMode load hang, popover-behind-modal, bell aria attrs - useAnnouncements: re-arm the alive ref on every effect mount instead of only setting it false on cleanup. StrictMode's dev mount->cleanup->mount cycle left it permanently false, so isLoading never cleared in any host wrapped in (sphere, sphere-quest-frontend). - AnnouncementBell: close the popover when a row is clicked. The click lands inside rootRef so the outside-click handler never catches it, leaving the popover open behind whatever modal onOpenItem triggers. - AnnouncementBell: add aria-haspopup/aria-expanded to the bell trigger so screen readers get a signal the popover opened. Adds regression tests for all three (StrictMode render, popover-closes-on row-click, aria attribute assertions). --- .../announcements/AnnouncementBell.tsx | 7 +++++++ .../__tests__/AnnouncementBell.test.tsx | 20 +++++++++++++++++++ .../__tests__/useAnnouncements.test.tsx | 13 ++++++++++++ .../announcements/useAnnouncements.ts | 14 ++++++++++++- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/components/announcements/AnnouncementBell.tsx b/src/components/announcements/AnnouncementBell.tsx index 5ce133b..0fe9d29 100644 --- a/src/components/announcements/AnnouncementBell.tsx +++ b/src/components/announcements/AnnouncementBell.tsx @@ -73,6 +73,11 @@ export function AnnouncementBell({ // same click, not two. onOpenItem(announcement); onMarkRead(announcement.id, 'popover'); + // The row click is inside rootRef, so the outside-click handler above + // never fires for it — without this, the popover is left open behind + // the modal onOpenItem just triggered, and dismissing the modal reveals + // it still hanging over the page. + setOpen(false); }; const badge = unreadCount > 0 ? (unreadCount > 99 ? '99+' : String(unreadCount)) : null; @@ -83,6 +88,8 @@ export function AnnouncementBell({