featuredrop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ /** A single feature entry in the manifest */
2
+ interface FeatureEntry {
3
+ /** Unique identifier for the feature */
4
+ id: string;
5
+ /** Human-readable label (e.g. "Decision Journal") */
6
+ label: string;
7
+ /** Optional longer description */
8
+ description?: string;
9
+ /** ISO date when this feature was released */
10
+ releasedAt: string;
11
+ /** ISO date after which the "new" badge should stop showing */
12
+ showNewUntil: string;
13
+ /** Optional key to match navigation items (e.g. "/journal", "settings") */
14
+ sidebarKey?: string;
15
+ /** Optional grouping category (e.g. "ai", "billing", "core") */
16
+ category?: string;
17
+ /** Optional URL to link to (e.g. docs page, changelog entry) */
18
+ url?: string;
19
+ /** Optional version string when this feature shipped */
20
+ version?: string;
21
+ /** Optional arbitrary metadata */
22
+ meta?: Record<string, unknown>;
23
+ }
24
+ /** The full feature manifest — an array of feature entries */
25
+ type FeatureManifest = readonly FeatureEntry[];
26
+ /**
27
+ * Storage adapter interface — implement for your persistence layer.
28
+ *
29
+ * The adapter bridges two data sources:
30
+ * - **Watermark**: a server-side timestamp ("features seen at")
31
+ * - **Dismissed IDs**: client-side per-feature dismissals
32
+ */
33
+ interface StorageAdapter {
34
+ /** Get the user's "features seen at" watermark (ISO string or null) */
35
+ getWatermark(): string | null;
36
+ /** Get the set of individually dismissed feature IDs */
37
+ getDismissedIds(): ReadonlySet<string>;
38
+ /** Dismiss a single feature by ID */
39
+ dismiss(id: string): void;
40
+ /** Dismiss all features — sets watermark to `now` and clears dismissals */
41
+ dismissAll(now: Date): Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Check if a single feature should show as "new".
46
+ *
47
+ * A feature is "new" when ALL of these are true:
48
+ * 1. Current time is before `showNewUntil`
49
+ * 2. Feature was released after the watermark (or no watermark exists)
50
+ * 3. Feature has not been individually dismissed
51
+ */
52
+ declare function isNew(feature: FeatureEntry, watermark: string | null, dismissedIds: ReadonlySet<string>, now?: Date): boolean;
53
+ /**
54
+ * Get all features that are currently "new" for this user.
55
+ */
56
+ declare function getNewFeatures(manifest: FeatureManifest, storage: StorageAdapter, now?: Date): FeatureEntry[];
57
+ /**
58
+ * Get the count of new features.
59
+ */
60
+ declare function getNewFeatureCount(manifest: FeatureManifest, storage: StorageAdapter, now?: Date): number;
61
+ /**
62
+ * Check if a specific sidebar key has a new feature.
63
+ */
64
+ declare function hasNewFeature(manifest: FeatureManifest, sidebarKey: string, storage: StorageAdapter, now?: Date): boolean;
65
+
66
+ /**
67
+ * Create a frozen feature manifest from an array of entries.
68
+ * Ensures the manifest is immutable at runtime.
69
+ */
70
+ declare function createManifest(entries: FeatureEntry[]): FeatureManifest;
71
+ /**
72
+ * Find a feature by its ID in the manifest.
73
+ * Returns `undefined` if not found.
74
+ */
75
+ declare function getFeatureById(manifest: FeatureManifest, id: string): FeatureEntry | undefined;
76
+ /**
77
+ * Get all new features in a specific category.
78
+ */
79
+ declare function getNewFeaturesByCategory(manifest: FeatureManifest, category: string, storage: StorageAdapter, now?: Date): FeatureEntry[];
80
+
81
+ interface LocalStorageAdapterOptions {
82
+ /** Key prefix for localStorage entries. Default: "featuredrop" */
83
+ prefix?: string;
84
+ /** Server-side watermark (ISO string). Typically from user profile. */
85
+ watermark?: string | null;
86
+ /** Callback when dismissAll is called. Use for server-side watermark updates. */
87
+ onDismissAll?: (now: Date) => Promise<void>;
88
+ }
89
+ /**
90
+ * localStorage-based storage adapter.
91
+ *
92
+ * Architecture:
93
+ * - **Watermark** comes from the server (passed at construction time)
94
+ * - **Per-feature dismissals** are stored in localStorage (zero server writes)
95
+ * - **dismissAll()** optionally calls a server callback, then clears localStorage
96
+ *
97
+ * Gracefully handles SSR environments where `window`/`localStorage` is unavailable.
98
+ */
99
+ declare class LocalStorageAdapter implements StorageAdapter {
100
+ private readonly prefix;
101
+ private readonly watermarkValue;
102
+ private readonly onDismissAllCallback?;
103
+ private readonly dismissedKey;
104
+ constructor(options?: LocalStorageAdapterOptions);
105
+ getWatermark(): string | null;
106
+ getDismissedIds(): ReadonlySet<string>;
107
+ dismiss(id: string): void;
108
+ dismissAll(now: Date): Promise<void>;
109
+ }
110
+
111
+ /**
112
+ * In-memory storage adapter.
113
+ *
114
+ * Useful for:
115
+ * - Testing (no side effects)
116
+ * - Server-side rendering (no `window`/`localStorage`)
117
+ * - Environments without persistent storage
118
+ */
119
+ declare class MemoryAdapter implements StorageAdapter {
120
+ private watermark;
121
+ private dismissed;
122
+ constructor(options?: {
123
+ watermark?: string | null;
124
+ });
125
+ getWatermark(): string | null;
126
+ getDismissedIds(): ReadonlySet<string>;
127
+ dismiss(id: string): void;
128
+ dismissAll(now: Date): Promise<void>;
129
+ }
130
+
131
+ export { type FeatureEntry, type FeatureManifest, LocalStorageAdapter, type LocalStorageAdapterOptions, MemoryAdapter, type StorageAdapter, createManifest, getFeatureById, getNewFeatureCount, getNewFeatures, getNewFeaturesByCategory, hasNewFeature, isNew };
package/dist/index.js ADDED
@@ -0,0 +1,123 @@
1
+ // src/core.ts
2
+ function isNew(feature, watermark, dismissedIds, now = /* @__PURE__ */ new Date()) {
3
+ if (dismissedIds.has(feature.id)) return false;
4
+ const nowMs = now.getTime();
5
+ const showUntilMs = new Date(feature.showNewUntil).getTime();
6
+ if (nowMs >= showUntilMs) return false;
7
+ if (watermark) {
8
+ const watermarkMs = new Date(watermark).getTime();
9
+ const releasedMs = new Date(feature.releasedAt).getTime();
10
+ if (releasedMs <= watermarkMs) return false;
11
+ }
12
+ return true;
13
+ }
14
+ function getNewFeatures(manifest, storage, now = /* @__PURE__ */ new Date()) {
15
+ const watermark = storage.getWatermark();
16
+ const dismissedIds = storage.getDismissedIds();
17
+ return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));
18
+ }
19
+ function getNewFeatureCount(manifest, storage, now = /* @__PURE__ */ new Date()) {
20
+ return getNewFeatures(manifest, storage, now).length;
21
+ }
22
+ function hasNewFeature(manifest, sidebarKey, storage, now = /* @__PURE__ */ new Date()) {
23
+ const watermark = storage.getWatermark();
24
+ const dismissedIds = storage.getDismissedIds();
25
+ return manifest.some(
26
+ (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now)
27
+ );
28
+ }
29
+
30
+ // src/helpers.ts
31
+ function createManifest(entries) {
32
+ return Object.freeze([...entries]);
33
+ }
34
+ function getFeatureById(manifest, id) {
35
+ return manifest.find((f) => f.id === id);
36
+ }
37
+ function getNewFeaturesByCategory(manifest, category, storage, now = /* @__PURE__ */ new Date()) {
38
+ const watermark = storage.getWatermark();
39
+ const dismissedIds = storage.getDismissedIds();
40
+ return manifest.filter(
41
+ (f) => f.category === category && isNew(f, watermark, dismissedIds, now)
42
+ );
43
+ }
44
+
45
+ // src/adapters/local-storage.ts
46
+ var DISMISSED_SUFFIX = ":dismissed";
47
+ var LocalStorageAdapter = class {
48
+ prefix;
49
+ watermarkValue;
50
+ onDismissAllCallback;
51
+ dismissedKey;
52
+ constructor(options = {}) {
53
+ this.prefix = options.prefix ?? "featuredrop";
54
+ this.watermarkValue = options.watermark ?? null;
55
+ this.onDismissAllCallback = options.onDismissAll;
56
+ this.dismissedKey = `${this.prefix}${DISMISSED_SUFFIX}`;
57
+ }
58
+ getWatermark() {
59
+ return this.watermarkValue;
60
+ }
61
+ getDismissedIds() {
62
+ try {
63
+ if (typeof window === "undefined") return /* @__PURE__ */ new Set();
64
+ const raw = localStorage.getItem(this.dismissedKey);
65
+ if (!raw) return /* @__PURE__ */ new Set();
66
+ const parsed = JSON.parse(raw);
67
+ if (Array.isArray(parsed)) return new Set(parsed);
68
+ return /* @__PURE__ */ new Set();
69
+ } catch {
70
+ return /* @__PURE__ */ new Set();
71
+ }
72
+ }
73
+ dismiss(id) {
74
+ try {
75
+ if (typeof window === "undefined") return;
76
+ const raw = localStorage.getItem(this.dismissedKey);
77
+ const existing = raw ? JSON.parse(raw) : [];
78
+ if (!existing.includes(id)) {
79
+ existing.push(id);
80
+ localStorage.setItem(this.dismissedKey, JSON.stringify(existing));
81
+ }
82
+ } catch {
83
+ }
84
+ }
85
+ async dismissAll(now) {
86
+ try {
87
+ if (typeof window !== "undefined") {
88
+ localStorage.removeItem(this.dismissedKey);
89
+ }
90
+ } catch {
91
+ }
92
+ if (this.onDismissAllCallback) {
93
+ await this.onDismissAllCallback(now);
94
+ }
95
+ }
96
+ };
97
+
98
+ // src/adapters/memory.ts
99
+ var MemoryAdapter = class {
100
+ watermark;
101
+ dismissed;
102
+ constructor(options = {}) {
103
+ this.watermark = options.watermark ?? null;
104
+ this.dismissed = /* @__PURE__ */ new Set();
105
+ }
106
+ getWatermark() {
107
+ return this.watermark;
108
+ }
109
+ getDismissedIds() {
110
+ return this.dismissed;
111
+ }
112
+ dismiss(id) {
113
+ this.dismissed.add(id);
114
+ }
115
+ async dismissAll(now) {
116
+ this.watermark = now.toISOString();
117
+ this.dismissed.clear();
118
+ }
119
+ };
120
+
121
+ export { LocalStorageAdapter, MemoryAdapter, createManifest, getFeatureById, getNewFeatureCount, getNewFeatures, getNewFeaturesByCategory, hasNewFeature, isNew };
122
+ //# sourceMappingURL=index.js.map
123
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core.ts","../src/helpers.ts","../src/adapters/local-storage.ts","../src/adapters/memory.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\n\n/**\n * Check if a single feature should show as \"new\".\n *\n * A feature is \"new\" when ALL of these are true:\n * 1. Current time is before `showNewUntil`\n * 2. Feature was released after the watermark (or no watermark exists)\n * 3. Feature has not been individually dismissed\n */\nexport function isNew(\n feature: FeatureEntry,\n watermark: string | null,\n dismissedIds: ReadonlySet<string>,\n now: Date = new Date(),\n): boolean {\n // Already dismissed by the user on this device\n if (dismissedIds.has(feature.id)) return false;\n\n const nowMs = now.getTime();\n const showUntilMs = new Date(feature.showNewUntil).getTime();\n\n // Past the display window\n if (nowMs >= showUntilMs) return false;\n\n // If there's a watermark, feature must have been released after it\n if (watermark) {\n const watermarkMs = new Date(watermark).getTime();\n const releasedMs = new Date(feature.releasedAt).getTime();\n if (releasedMs <= watermarkMs) return false;\n }\n\n return true;\n}\n\n/**\n * Get all features that are currently \"new\" for this user.\n */\nexport function getNewFeatures(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));\n}\n\n/**\n * Get the count of new features.\n */\nexport function getNewFeatureCount(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): number {\n return getNewFeatures(manifest, storage, now).length;\n}\n\n/**\n * Check if a specific sidebar key has a new feature.\n */\nexport function hasNewFeature(\n manifest: FeatureManifest,\n sidebarKey: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): boolean {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.some(\n (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\nimport { isNew } from \"./core\";\n\n/**\n * Create a frozen feature manifest from an array of entries.\n * Ensures the manifest is immutable at runtime.\n */\nexport function createManifest(\n entries: FeatureEntry[],\n): FeatureManifest {\n return Object.freeze([...entries]);\n}\n\n/**\n * Find a feature by its ID in the manifest.\n * Returns `undefined` if not found.\n */\nexport function getFeatureById(\n manifest: FeatureManifest,\n id: string,\n): FeatureEntry | undefined {\n return manifest.find((f) => f.id === id);\n}\n\n/**\n * Get all new features in a specific category.\n */\nexport function getNewFeaturesByCategory(\n manifest: FeatureManifest,\n category: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter(\n (f) => f.category === category && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import type { StorageAdapter } from \"../types\";\n\nexport interface LocalStorageAdapterOptions {\n /** Key prefix for localStorage entries. Default: \"featuredrop\" */\n prefix?: string;\n /** Server-side watermark (ISO string). Typically from user profile. */\n watermark?: string | null;\n /** Callback when dismissAll is called. Use for server-side watermark updates. */\n onDismissAll?: (now: Date) => Promise<void>;\n}\n\nconst DISMISSED_SUFFIX = \":dismissed\";\n\n/**\n * localStorage-based storage adapter.\n *\n * Architecture:\n * - **Watermark** comes from the server (passed at construction time)\n * - **Per-feature dismissals** are stored in localStorage (zero server writes)\n * - **dismissAll()** optionally calls a server callback, then clears localStorage\n *\n * Gracefully handles SSR environments where `window`/`localStorage` is unavailable.\n */\nexport class LocalStorageAdapter implements StorageAdapter {\n private readonly prefix: string;\n private readonly watermarkValue: string | null;\n private readonly onDismissAllCallback?: (now: Date) => Promise<void>;\n private readonly dismissedKey: string;\n\n constructor(options: LocalStorageAdapterOptions = {}) {\n this.prefix = options.prefix ?? \"featuredrop\";\n this.watermarkValue = options.watermark ?? null;\n this.onDismissAllCallback = options.onDismissAll;\n this.dismissedKey = `${this.prefix}${DISMISSED_SUFFIX}`;\n }\n\n getWatermark(): string | null {\n return this.watermarkValue;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n try {\n if (typeof window === \"undefined\") return new Set();\n const raw = localStorage.getItem(this.dismissedKey);\n if (!raw) return new Set();\n const parsed: unknown = JSON.parse(raw);\n if (Array.isArray(parsed)) return new Set(parsed as string[]);\n return new Set();\n } catch {\n return new Set();\n }\n }\n\n dismiss(id: string): void {\n try {\n if (typeof window === \"undefined\") return;\n const raw = localStorage.getItem(this.dismissedKey);\n const existing: string[] = raw ? (JSON.parse(raw) as string[]) : [];\n if (!existing.includes(id)) {\n existing.push(id);\n localStorage.setItem(this.dismissedKey, JSON.stringify(existing));\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n }\n\n async dismissAll(now: Date): Promise<void> {\n try {\n if (typeof window !== \"undefined\") {\n localStorage.removeItem(this.dismissedKey);\n }\n } catch {\n // localStorage unavailable — silent fail\n }\n\n if (this.onDismissAllCallback) {\n await this.onDismissAllCallback(now);\n }\n }\n}\n","import type { StorageAdapter } from \"../types\";\n\n/**\n * In-memory storage adapter.\n *\n * Useful for:\n * - Testing (no side effects)\n * - Server-side rendering (no `window`/`localStorage`)\n * - Environments without persistent storage\n */\nexport class MemoryAdapter implements StorageAdapter {\n private watermark: string | null;\n private dismissed: Set<string>;\n\n constructor(options: { watermark?: string | null } = {}) {\n this.watermark = options.watermark ?? null;\n this.dismissed = new Set();\n }\n\n getWatermark(): string | null {\n return this.watermark;\n }\n\n getDismissedIds(): ReadonlySet<string> {\n return this.dismissed;\n }\n\n dismiss(id: string): void {\n this.dismissed.add(id);\n }\n\n async dismissAll(now: Date): Promise<void> {\n this.watermark = now.toISOString();\n this.dismissed.clear();\n }\n}\n"]}
package/dist/react.cjs ADDED
@@ -0,0 +1,197 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ var react = require('react');
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+
7
+ // src/react/provider.tsx
8
+
9
+ // src/core.ts
10
+ function isNew(feature, watermark, dismissedIds, now = /* @__PURE__ */ new Date()) {
11
+ if (dismissedIds.has(feature.id)) return false;
12
+ const nowMs = now.getTime();
13
+ const showUntilMs = new Date(feature.showNewUntil).getTime();
14
+ if (nowMs >= showUntilMs) return false;
15
+ if (watermark) {
16
+ const watermarkMs = new Date(watermark).getTime();
17
+ const releasedMs = new Date(feature.releasedAt).getTime();
18
+ if (releasedMs <= watermarkMs) return false;
19
+ }
20
+ return true;
21
+ }
22
+ function getNewFeatures(manifest, storage, now = /* @__PURE__ */ new Date()) {
23
+ const watermark = storage.getWatermark();
24
+ const dismissedIds = storage.getDismissedIds();
25
+ return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));
26
+ }
27
+ function hasNewFeature(manifest, sidebarKey, storage, now = /* @__PURE__ */ new Date()) {
28
+ const watermark = storage.getWatermark();
29
+ const dismissedIds = storage.getDismissedIds();
30
+ return manifest.some(
31
+ (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now)
32
+ );
33
+ }
34
+ var FeatureDropContext = react.createContext(
35
+ null
36
+ );
37
+ function FeatureDropProvider({
38
+ manifest,
39
+ storage,
40
+ children
41
+ }) {
42
+ const [newFeatures, setNewFeatures] = react.useState(
43
+ () => getNewFeatures(manifest, storage)
44
+ );
45
+ const recompute = react.useCallback(() => {
46
+ setNewFeatures(getNewFeatures(manifest, storage));
47
+ }, [manifest, storage]);
48
+ const dismiss = react.useCallback(
49
+ (id) => {
50
+ storage.dismiss(id);
51
+ recompute();
52
+ },
53
+ [storage, recompute]
54
+ );
55
+ const dismissAll = react.useCallback(async () => {
56
+ await storage.dismissAll(/* @__PURE__ */ new Date());
57
+ setNewFeatures([]);
58
+ }, [storage]);
59
+ const isNewFn = react.useCallback(
60
+ (sidebarKey) => hasNewFeature(manifest, sidebarKey, storage),
61
+ [manifest, storage]
62
+ );
63
+ const getFeature = react.useCallback(
64
+ (sidebarKey) => newFeatures.find((f) => f.sidebarKey === sidebarKey),
65
+ [newFeatures]
66
+ );
67
+ const value = react.useMemo(
68
+ () => ({
69
+ newFeatures,
70
+ newCount: newFeatures.length,
71
+ isNew: isNewFn,
72
+ dismiss,
73
+ dismissAll,
74
+ getFeature
75
+ }),
76
+ [newFeatures, isNewFn, dismiss, dismissAll, getFeature]
77
+ );
78
+ return /* @__PURE__ */ jsxRuntime.jsx(FeatureDropContext.Provider, { value, children });
79
+ }
80
+ function useFeatureDrop() {
81
+ const context = react.useContext(FeatureDropContext);
82
+ if (!context) {
83
+ throw new Error(
84
+ "useFeatureDrop must be used within a <FeatureDropProvider>"
85
+ );
86
+ }
87
+ return context;
88
+ }
89
+
90
+ // src/react/hooks/use-new-feature.ts
91
+ function useNewFeature(sidebarKey) {
92
+ const { isNew: isNew2, getFeature, dismiss } = useFeatureDrop();
93
+ const feature = getFeature(sidebarKey);
94
+ const isNewValue = isNew2(sidebarKey);
95
+ return {
96
+ isNew: isNewValue,
97
+ feature,
98
+ dismiss: () => {
99
+ if (feature) {
100
+ dismiss(feature.id);
101
+ }
102
+ }
103
+ };
104
+ }
105
+
106
+ // src/react/hooks/use-new-count.ts
107
+ function useNewCount() {
108
+ const { newCount } = useFeatureDrop();
109
+ return newCount;
110
+ }
111
+ var baseStyles = {
112
+ display: "inline-flex",
113
+ alignItems: "center",
114
+ justifyContent: "center",
115
+ fontFamily: "inherit"
116
+ };
117
+ var pillStyles = {
118
+ ...baseStyles,
119
+ padding: "2px 6px",
120
+ borderRadius: "9999px",
121
+ fontSize: "var(--featuredrop-font-size, 10px)",
122
+ fontWeight: 700,
123
+ textTransform: "uppercase",
124
+ letterSpacing: "0.05em",
125
+ lineHeight: 1,
126
+ color: "var(--featuredrop-color, #b45309)",
127
+ backgroundColor: "var(--featuredrop-bg, rgba(245, 158, 11, 0.15))"
128
+ };
129
+ var dotStyles = {
130
+ ...baseStyles,
131
+ width: "var(--featuredrop-dot-size, 8px)",
132
+ height: "var(--featuredrop-dot-size, 8px)",
133
+ borderRadius: "9999px",
134
+ backgroundColor: "var(--featuredrop-color, #f59e0b)",
135
+ boxShadow: "0 0 6px var(--featuredrop-glow, rgba(245, 158, 11, 0.6))",
136
+ animation: "featuredrop-pulse 2s ease-in-out infinite"
137
+ };
138
+ var countStyles = {
139
+ ...baseStyles,
140
+ minWidth: "var(--featuredrop-count-size, 18px)",
141
+ height: "var(--featuredrop-count-size, 18px)",
142
+ padding: "0 4px",
143
+ borderRadius: "9999px",
144
+ fontSize: "var(--featuredrop-font-size, 11px)",
145
+ fontWeight: 700,
146
+ lineHeight: 1,
147
+ color: "var(--featuredrop-count-color, white)",
148
+ backgroundColor: "var(--featuredrop-count-bg, #f59e0b)"
149
+ };
150
+ function NewBadge({
151
+ variant = "pill",
152
+ show = true,
153
+ count,
154
+ label = "New",
155
+ onDismiss,
156
+ dismissOnClick = false,
157
+ className,
158
+ style,
159
+ children
160
+ }) {
161
+ if (children) {
162
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children({ isNew: show }) });
163
+ }
164
+ if (!show) return null;
165
+ const handleClick = dismissOnClick && onDismiss ? onDismiss : void 0;
166
+ const variantStyles = variant === "dot" ? dotStyles : variant === "count" ? countStyles : pillStyles;
167
+ const content = variant === "dot" ? null : variant === "count" ? count ?? 0 : label;
168
+ const ariaLabel = variant === "count" ? `${count ?? 0} new features` : "New feature";
169
+ return /* @__PURE__ */ jsxRuntime.jsx(
170
+ "span",
171
+ {
172
+ "data-featuredrop": variant,
173
+ className,
174
+ style: { ...variantStyles, ...style },
175
+ onClick: handleClick,
176
+ role: dismissOnClick ? "button" : void 0,
177
+ tabIndex: dismissOnClick ? 0 : void 0,
178
+ onKeyDown: dismissOnClick && onDismiss ? (e) => {
179
+ if (e.key === "Enter" || e.key === " ") {
180
+ e.preventDefault();
181
+ onDismiss();
182
+ }
183
+ } : void 0,
184
+ "aria-label": ariaLabel,
185
+ children: content
186
+ }
187
+ );
188
+ }
189
+
190
+ exports.FeatureDropContext = FeatureDropContext;
191
+ exports.FeatureDropProvider = FeatureDropProvider;
192
+ exports.NewBadge = NewBadge;
193
+ exports.useFeatureDrop = useFeatureDrop;
194
+ exports.useNewCount = useNewCount;
195
+ exports.useNewFeature = useNewFeature;
196
+ //# sourceMappingURL=react.cjs.map
197
+ //# sourceMappingURL=react.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core.ts","../src/react/context.ts","../src/react/provider.tsx","../src/react/hooks/use-feature-drop.ts","../src/react/hooks/use-new-feature.ts","../src/react/hooks/use-new-count.ts","../src/react/components/new-badge.tsx"],"names":["createContext","useState","useCallback","useMemo","jsx","useContext","isNew","Fragment"],"mappings":"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","file":"react.cjs","sourcesContent":["import type { FeatureEntry, FeatureManifest, StorageAdapter } from \"./types\";\n\n/**\n * Check if a single feature should show as \"new\".\n *\n * A feature is \"new\" when ALL of these are true:\n * 1. Current time is before `showNewUntil`\n * 2. Feature was released after the watermark (or no watermark exists)\n * 3. Feature has not been individually dismissed\n */\nexport function isNew(\n feature: FeatureEntry,\n watermark: string | null,\n dismissedIds: ReadonlySet<string>,\n now: Date = new Date(),\n): boolean {\n // Already dismissed by the user on this device\n if (dismissedIds.has(feature.id)) return false;\n\n const nowMs = now.getTime();\n const showUntilMs = new Date(feature.showNewUntil).getTime();\n\n // Past the display window\n if (nowMs >= showUntilMs) return false;\n\n // If there's a watermark, feature must have been released after it\n if (watermark) {\n const watermarkMs = new Date(watermark).getTime();\n const releasedMs = new Date(feature.releasedAt).getTime();\n if (releasedMs <= watermarkMs) return false;\n }\n\n return true;\n}\n\n/**\n * Get all features that are currently \"new\" for this user.\n */\nexport function getNewFeatures(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): FeatureEntry[] {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.filter((f) => isNew(f, watermark, dismissedIds, now));\n}\n\n/**\n * Get the count of new features.\n */\nexport function getNewFeatureCount(\n manifest: FeatureManifest,\n storage: StorageAdapter,\n now: Date = new Date(),\n): number {\n return getNewFeatures(manifest, storage, now).length;\n}\n\n/**\n * Check if a specific sidebar key has a new feature.\n */\nexport function hasNewFeature(\n manifest: FeatureManifest,\n sidebarKey: string,\n storage: StorageAdapter,\n now: Date = new Date(),\n): boolean {\n const watermark = storage.getWatermark();\n const dismissedIds = storage.getDismissedIds();\n return manifest.some(\n (f) => f.sidebarKey === sidebarKey && isNew(f, watermark, dismissedIds, now),\n );\n}\n","import { createContext } from \"react\";\nimport type { FeatureEntry } from \"../types\";\n\nexport interface FeatureDropContextValue {\n /** All currently \"new\" features */\n newFeatures: FeatureEntry[];\n /** Count of new features */\n newCount: number;\n /** Check if a sidebar key has any new features */\n isNew: (sidebarKey: string) => boolean;\n /** Dismiss a single feature by ID */\n dismiss: (id: string) => void;\n /** Dismiss all features (marks all as seen) */\n dismissAll: () => Promise<void>;\n /** Get the feature entry for a sidebar key (if it's new) */\n getFeature: (sidebarKey: string) => FeatureEntry | undefined;\n}\n\nexport const FeatureDropContext = createContext<FeatureDropContextValue | null>(\n null,\n);\n","import { useState, useCallback, useMemo, type ReactNode } from \"react\";\nimport type { FeatureManifest, StorageAdapter } from \"../types\";\nimport { getNewFeatures, hasNewFeature } from \"../core\";\nimport { FeatureDropContext } from \"./context\";\n\nexport interface FeatureDropProviderProps {\n /** The feature manifest — typically a frozen array of FeatureEntry objects */\n manifest: FeatureManifest;\n /** Storage adapter instance (e.g. LocalStorageAdapter, MemoryAdapter) */\n storage: StorageAdapter;\n children: ReactNode;\n}\n\n/**\n * Provides feature discovery state to the component tree.\n *\n * Wrap your app (or a subtree) with this provider to enable\n * `useFeatureDrop`, `useNewFeature`, and `useNewCount` hooks.\n */\nexport function FeatureDropProvider({\n manifest,\n storage,\n children,\n}: FeatureDropProviderProps) {\n const [newFeatures, setNewFeatures] = useState(() =>\n getNewFeatures(manifest, storage),\n );\n\n const recompute = useCallback(() => {\n setNewFeatures(getNewFeatures(manifest, storage));\n }, [manifest, storage]);\n\n const dismiss = useCallback(\n (id: string) => {\n storage.dismiss(id);\n recompute();\n },\n [storage, recompute],\n );\n\n const dismissAll = useCallback(async () => {\n await storage.dismissAll(new Date());\n setNewFeatures([]);\n }, [storage]);\n\n const isNewFn = useCallback(\n (sidebarKey: string) => hasNewFeature(manifest, sidebarKey, storage),\n [manifest, storage],\n );\n\n const getFeature = useCallback(\n (sidebarKey: string) =>\n newFeatures.find((f) => f.sidebarKey === sidebarKey),\n [newFeatures],\n );\n\n const value = useMemo(\n () => ({\n newFeatures,\n newCount: newFeatures.length,\n isNew: isNewFn,\n dismiss,\n dismissAll,\n getFeature,\n }),\n [newFeatures, isNewFn, dismiss, dismissAll, getFeature],\n );\n\n return (\n <FeatureDropContext.Provider value={value}>\n {children}\n </FeatureDropContext.Provider>\n );\n}\n","import { useContext } from \"react\";\nimport { FeatureDropContext } from \"../context\";\nimport type { FeatureDropContextValue } from \"../context\";\n\n/**\n * Access the full feature discovery context.\n *\n * Returns: `{ newFeatures, newCount, isNew, dismiss, dismissAll, getFeature }`\n *\n * @throws Error if used outside of `<FeatureDropProvider>`\n */\nexport function useFeatureDrop(): FeatureDropContextValue {\n const context = useContext(FeatureDropContext);\n if (!context) {\n throw new Error(\n \"useFeatureDrop must be used within a <FeatureDropProvider>\",\n );\n }\n return context;\n}\n","import { useFeatureDrop } from \"./use-feature-drop\";\nimport type { FeatureEntry } from \"../../types\";\n\nexport interface UseNewFeatureResult {\n /** Whether this sidebar key has a new feature */\n isNew: boolean;\n /** The feature entry, if new */\n feature: FeatureEntry | undefined;\n /** Dismiss the feature for this sidebar key */\n dismiss: () => void;\n}\n\n/**\n * Check if a single navigation item has a new feature.\n *\n * @param sidebarKey - The key to check (e.g. \"/journal\", \"settings\")\n * @returns `{ isNew, feature, dismiss }`\n */\nexport function useNewFeature(sidebarKey: string): UseNewFeatureResult {\n const { isNew, getFeature, dismiss } = useFeatureDrop();\n\n const feature = getFeature(sidebarKey);\n const isNewValue = isNew(sidebarKey);\n\n return {\n isNew: isNewValue,\n feature,\n dismiss: () => {\n if (feature) {\n dismiss(feature.id);\n }\n },\n };\n}\n","import { useFeatureDrop } from \"./use-feature-drop\";\n\n/**\n * Get the count of currently new features.\n *\n * Useful for rendering a badge count on a \"What's New\" button.\n *\n * @returns The number of new features\n */\nexport function useNewCount(): number {\n const { newCount } = useFeatureDrop();\n return newCount;\n}\n","import type { ReactNode, CSSProperties } from \"react\";\n\nexport interface NewBadgeRenderProps {\n /** Whether the feature is currently new */\n isNew: boolean;\n}\n\nexport interface NewBadgeProps {\n /** Display variant */\n variant?: \"pill\" | \"dot\" | \"count\";\n /** Whether to show the badge (typically from `useNewFeature().isNew`) */\n show?: boolean;\n /** Count to display when variant is \"count\" */\n count?: number;\n /** Text label for the pill variant. Default: \"New\" */\n label?: string;\n /** Dismiss callback. If set with `dismissOnClick`, clicking dismisses. */\n onDismiss?: () => void;\n /** Whether clicking the badge should trigger onDismiss */\n dismissOnClick?: boolean;\n /** Additional CSS class */\n className?: string;\n /** Additional inline styles (merged with defaults) */\n style?: CSSProperties;\n /** Render prop for full customization */\n children?: (props: NewBadgeRenderProps) => ReactNode;\n}\n\nconst baseStyles: CSSProperties = {\n display: \"inline-flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n fontFamily: \"inherit\",\n};\n\nconst pillStyles: CSSProperties = {\n ...baseStyles,\n padding: \"2px 6px\",\n borderRadius: \"9999px\",\n fontSize: \"var(--featuredrop-font-size, 10px)\",\n fontWeight: 700,\n textTransform: \"uppercase\" as const,\n letterSpacing: \"0.05em\",\n lineHeight: 1,\n color: \"var(--featuredrop-color, #b45309)\",\n backgroundColor: \"var(--featuredrop-bg, rgba(245, 158, 11, 0.15))\",\n};\n\nconst dotStyles: CSSProperties = {\n ...baseStyles,\n width: \"var(--featuredrop-dot-size, 8px)\",\n height: \"var(--featuredrop-dot-size, 8px)\",\n borderRadius: \"9999px\",\n backgroundColor: \"var(--featuredrop-color, #f59e0b)\",\n boxShadow: \"0 0 6px var(--featuredrop-glow, rgba(245, 158, 11, 0.6))\",\n animation: \"featuredrop-pulse 2s ease-in-out infinite\",\n};\n\nconst countStyles: CSSProperties = {\n ...baseStyles,\n minWidth: \"var(--featuredrop-count-size, 18px)\",\n height: \"var(--featuredrop-count-size, 18px)\",\n padding: \"0 4px\",\n borderRadius: \"9999px\",\n fontSize: \"var(--featuredrop-font-size, 11px)\",\n fontWeight: 700,\n lineHeight: 1,\n color: \"var(--featuredrop-count-color, white)\",\n backgroundColor: \"var(--featuredrop-count-bg, #f59e0b)\",\n};\n\n/**\n * Headless \"New\" badge component.\n *\n * Styled via CSS custom properties — zero CSS framework dependency:\n * - `--featuredrop-color` — text/dot color\n * - `--featuredrop-bg` — pill background\n * - `--featuredrop-font-size` — font size\n * - `--featuredrop-dot-size` — dot diameter\n * - `--featuredrop-glow` — dot glow color\n * - `--featuredrop-count-size` — count badge size\n * - `--featuredrop-count-color` — count text color\n * - `--featuredrop-count-bg` — count background\n *\n * Use `data-featuredrop` attribute for CSS selector styling.\n */\nexport function NewBadge({\n variant = \"pill\",\n show = true,\n count,\n label = \"New\",\n onDismiss,\n dismissOnClick = false,\n className,\n style,\n children,\n}: NewBadgeProps) {\n // Render prop mode\n if (children) {\n return <>{children({ isNew: show })}</>;\n }\n\n if (!show) return null;\n\n const handleClick = dismissOnClick && onDismiss ? onDismiss : undefined;\n\n const variantStyles =\n variant === \"dot\"\n ? dotStyles\n : variant === \"count\"\n ? countStyles\n : pillStyles;\n\n const content =\n variant === \"dot\"\n ? null\n : variant === \"count\"\n ? (count ?? 0)\n : label;\n\n const ariaLabel =\n variant === \"count\"\n ? `${count ?? 0} new features`\n : \"New feature\";\n\n return (\n <span\n data-featuredrop={variant}\n className={className}\n style={{ ...variantStyles, ...style }}\n onClick={handleClick}\n role={dismissOnClick ? \"button\" : undefined}\n tabIndex={dismissOnClick ? 0 : undefined}\n onKeyDown={\n dismissOnClick && onDismiss\n ? (e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onDismiss();\n }\n }\n : undefined\n }\n aria-label={ariaLabel}\n >\n {content}\n </span>\n );\n}\n"]}
@@ -0,0 +1,154 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import * as react from 'react';
3
+ import { ReactNode, CSSProperties } from 'react';
4
+
5
+ /** A single feature entry in the manifest */
6
+ interface FeatureEntry {
7
+ /** Unique identifier for the feature */
8
+ id: string;
9
+ /** Human-readable label (e.g. "Decision Journal") */
10
+ label: string;
11
+ /** Optional longer description */
12
+ description?: string;
13
+ /** ISO date when this feature was released */
14
+ releasedAt: string;
15
+ /** ISO date after which the "new" badge should stop showing */
16
+ showNewUntil: string;
17
+ /** Optional key to match navigation items (e.g. "/journal", "settings") */
18
+ sidebarKey?: string;
19
+ /** Optional grouping category (e.g. "ai", "billing", "core") */
20
+ category?: string;
21
+ /** Optional URL to link to (e.g. docs page, changelog entry) */
22
+ url?: string;
23
+ /** Optional version string when this feature shipped */
24
+ version?: string;
25
+ /** Optional arbitrary metadata */
26
+ meta?: Record<string, unknown>;
27
+ }
28
+ /** The full feature manifest — an array of feature entries */
29
+ type FeatureManifest = readonly FeatureEntry[];
30
+ /**
31
+ * Storage adapter interface — implement for your persistence layer.
32
+ *
33
+ * The adapter bridges two data sources:
34
+ * - **Watermark**: a server-side timestamp ("features seen at")
35
+ * - **Dismissed IDs**: client-side per-feature dismissals
36
+ */
37
+ interface StorageAdapter {
38
+ /** Get the user's "features seen at" watermark (ISO string or null) */
39
+ getWatermark(): string | null;
40
+ /** Get the set of individually dismissed feature IDs */
41
+ getDismissedIds(): ReadonlySet<string>;
42
+ /** Dismiss a single feature by ID */
43
+ dismiss(id: string): void;
44
+ /** Dismiss all features — sets watermark to `now` and clears dismissals */
45
+ dismissAll(now: Date): Promise<void>;
46
+ }
47
+
48
+ interface FeatureDropProviderProps {
49
+ /** The feature manifest — typically a frozen array of FeatureEntry objects */
50
+ manifest: FeatureManifest;
51
+ /** Storage adapter instance (e.g. LocalStorageAdapter, MemoryAdapter) */
52
+ storage: StorageAdapter;
53
+ children: ReactNode;
54
+ }
55
+ /**
56
+ * Provides feature discovery state to the component tree.
57
+ *
58
+ * Wrap your app (or a subtree) with this provider to enable
59
+ * `useFeatureDrop`, `useNewFeature`, and `useNewCount` hooks.
60
+ */
61
+ declare function FeatureDropProvider({ manifest, storage, children, }: FeatureDropProviderProps): react_jsx_runtime.JSX.Element;
62
+
63
+ interface FeatureDropContextValue {
64
+ /** All currently "new" features */
65
+ newFeatures: FeatureEntry[];
66
+ /** Count of new features */
67
+ newCount: number;
68
+ /** Check if a sidebar key has any new features */
69
+ isNew: (sidebarKey: string) => boolean;
70
+ /** Dismiss a single feature by ID */
71
+ dismiss: (id: string) => void;
72
+ /** Dismiss all features (marks all as seen) */
73
+ dismissAll: () => Promise<void>;
74
+ /** Get the feature entry for a sidebar key (if it's new) */
75
+ getFeature: (sidebarKey: string) => FeatureEntry | undefined;
76
+ }
77
+ declare const FeatureDropContext: react.Context<FeatureDropContextValue | null>;
78
+
79
+ /**
80
+ * Access the full feature discovery context.
81
+ *
82
+ * Returns: `{ newFeatures, newCount, isNew, dismiss, dismissAll, getFeature }`
83
+ *
84
+ * @throws Error if used outside of `<FeatureDropProvider>`
85
+ */
86
+ declare function useFeatureDrop(): FeatureDropContextValue;
87
+
88
+ interface UseNewFeatureResult {
89
+ /** Whether this sidebar key has a new feature */
90
+ isNew: boolean;
91
+ /** The feature entry, if new */
92
+ feature: FeatureEntry | undefined;
93
+ /** Dismiss the feature for this sidebar key */
94
+ dismiss: () => void;
95
+ }
96
+ /**
97
+ * Check if a single navigation item has a new feature.
98
+ *
99
+ * @param sidebarKey - The key to check (e.g. "/journal", "settings")
100
+ * @returns `{ isNew, feature, dismiss }`
101
+ */
102
+ declare function useNewFeature(sidebarKey: string): UseNewFeatureResult;
103
+
104
+ /**
105
+ * Get the count of currently new features.
106
+ *
107
+ * Useful for rendering a badge count on a "What's New" button.
108
+ *
109
+ * @returns The number of new features
110
+ */
111
+ declare function useNewCount(): number;
112
+
113
+ interface NewBadgeRenderProps {
114
+ /** Whether the feature is currently new */
115
+ isNew: boolean;
116
+ }
117
+ interface NewBadgeProps {
118
+ /** Display variant */
119
+ variant?: "pill" | "dot" | "count";
120
+ /** Whether to show the badge (typically from `useNewFeature().isNew`) */
121
+ show?: boolean;
122
+ /** Count to display when variant is "count" */
123
+ count?: number;
124
+ /** Text label for the pill variant. Default: "New" */
125
+ label?: string;
126
+ /** Dismiss callback. If set with `dismissOnClick`, clicking dismisses. */
127
+ onDismiss?: () => void;
128
+ /** Whether clicking the badge should trigger onDismiss */
129
+ dismissOnClick?: boolean;
130
+ /** Additional CSS class */
131
+ className?: string;
132
+ /** Additional inline styles (merged with defaults) */
133
+ style?: CSSProperties;
134
+ /** Render prop for full customization */
135
+ children?: (props: NewBadgeRenderProps) => ReactNode;
136
+ }
137
+ /**
138
+ * Headless "New" badge component.
139
+ *
140
+ * Styled via CSS custom properties — zero CSS framework dependency:
141
+ * - `--featuredrop-color` — text/dot color
142
+ * - `--featuredrop-bg` — pill background
143
+ * - `--featuredrop-font-size` — font size
144
+ * - `--featuredrop-dot-size` — dot diameter
145
+ * - `--featuredrop-glow` — dot glow color
146
+ * - `--featuredrop-count-size` — count badge size
147
+ * - `--featuredrop-count-color` — count text color
148
+ * - `--featuredrop-count-bg` — count background
149
+ *
150
+ * Use `data-featuredrop` attribute for CSS selector styling.
151
+ */
152
+ declare function NewBadge({ variant, show, count, label, onDismiss, dismissOnClick, className, style, children, }: NewBadgeProps): react_jsx_runtime.JSX.Element | null;
153
+
154
+ export { FeatureDropContext, type FeatureDropContextValue, FeatureDropProvider, type FeatureDropProviderProps, NewBadge, type NewBadgeProps, type NewBadgeRenderProps, type UseNewFeatureResult, useFeatureDrop, useNewCount, useNewFeature };