feedly-widget 0.1.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,30 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { Anchor, Annotation, AnnotationStatus, AnnotationType, ShapeData, TesterRef } from '../types';
3
+ type Args = {
4
+ supabase: SupabaseClient;
5
+ projectId: string;
6
+ testerId: string | null;
7
+ isAdmin: boolean;
8
+ };
9
+ export declare function useAnnotations({ supabase, projectId, testerId, isAdmin }: Args): {
10
+ annotations: Annotation[];
11
+ testers: TesterRef[];
12
+ loading: boolean;
13
+ save: (type: AnnotationType, shape: ShapeData, comment: string, anchor: Anchor | null, noteOffset: {
14
+ x: number;
15
+ y: number;
16
+ } | null) => Promise<Annotation | null>;
17
+ remove: (id: string) => Promise<boolean>;
18
+ updateShape: (id: string, shape: ShapeData) => Promise<boolean>;
19
+ updateComment: (id: string, comment: string, noteOffset: {
20
+ x: number;
21
+ y: number;
22
+ } | null) => Promise<boolean>;
23
+ setStatus: (annotationId: string, newStatus: AnnotationStatus, comment?: string) => Promise<{
24
+ ok: true;
25
+ } | {
26
+ ok: false;
27
+ error: string;
28
+ }>;
29
+ };
30
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { Annotation } from '../types';
3
+ type Args = {
4
+ supabase: SupabaseClient;
5
+ projectId: string;
6
+ testerId: string | null;
7
+ isAdmin: boolean;
8
+ enabled: boolean;
9
+ };
10
+ export type AnnotationsByPage = {
11
+ pagePath: string;
12
+ pageUrl: string;
13
+ items: Annotation[];
14
+ }[];
15
+ export declare function useAnnotationsList({ supabase, projectId, testerId, isAdmin, enabled, }: Args): {
16
+ annotations: Annotation[];
17
+ grouped: AnnotationsByPage;
18
+ loading: boolean;
19
+ refresh: () => Promise<void>;
20
+ };
21
+ export {};
@@ -0,0 +1,34 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { Flow, FlowStep } from '../types';
3
+ export type PersistedReplay = {
4
+ flowId: string;
5
+ stepIndex: number;
6
+ projectId: string;
7
+ };
8
+ export declare function loadReplayState(projectId: string): PersistedReplay | null;
9
+ export declare function saveReplayState(s: PersistedReplay): void;
10
+ export declare function clearReplayState(): void;
11
+ export type PersistedRecording = {
12
+ projectId: string;
13
+ steps: FlowStep[];
14
+ startedAt: number;
15
+ };
16
+ export declare function loadRecordingState(projectId: string): PersistedRecording | null;
17
+ export declare function saveRecordingState(s: PersistedRecording): void;
18
+ export declare function clearRecordingState(): void;
19
+ type Args = {
20
+ supabase: SupabaseClient;
21
+ projectId: string;
22
+ testerId: string | null;
23
+ isAdmin: boolean;
24
+ enabled: boolean;
25
+ };
26
+ export declare function useFlows({ supabase, projectId, testerId, isAdmin, enabled }: Args): {
27
+ flows: Flow[];
28
+ loading: boolean;
29
+ save: (steps: FlowStep[], name?: string) => Promise<Flow | null>;
30
+ remove: (id: string) => Promise<boolean>;
31
+ fetchOne: (flowId: string) => Promise<Flow | null>;
32
+ refresh: () => Promise<void>;
33
+ };
34
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ type State = 'checking' | 'allowed' | 'blocked';
3
+ export declare function useInvite({ supabase, projectId, enabled, recheckKey, }: {
4
+ supabase: SupabaseClient;
5
+ projectId: string;
6
+ enabled: boolean;
7
+ recheckKey?: string | null;
8
+ }): State;
9
+ export {};
@@ -0,0 +1,6 @@
1
+ type Location = {
2
+ href: string;
3
+ pathname: string;
4
+ };
5
+ export declare function useLocation(): Location;
6
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { Project } from '../types';
3
+ type SessionState = {
4
+ loading: boolean;
5
+ project: Project | null;
6
+ };
7
+ export declare function useSession(supabase: SupabaseClient, projectId: string): SessionState;
8
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { StoredTesterIdentity } from '../types';
3
+ export declare function useTester(supabase: SupabaseClient, projectId: string): {
4
+ register: (name: string, email?: string) => Promise<StoredTesterIdentity | null>;
5
+ sendCrossDeviceLink: (email: string) => Promise<{
6
+ ok: true;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ }>;
11
+ claimAdmin: () => Promise<void>;
12
+ signOut: () => void;
13
+ identity: StoredTesterIdentity | null;
14
+ isAdmin: boolean;
15
+ initializing: boolean;
16
+ };
@@ -0,0 +1,9 @@
1
+ import type { Annotation } from '../types';
2
+ export declare function useUnseen({ projectId, annotations, currentTesterId, }: {
3
+ projectId: string;
4
+ annotations: Annotation[];
5
+ currentTesterId: string | null;
6
+ }): {
7
+ hasUnseen: boolean;
8
+ markSeen: () => void;
9
+ };
@@ -0,0 +1,5 @@
1
+ import type { FeedlyAPI } from './types';
2
+ declare const Feedly: FeedlyAPI;
3
+ export default Feedly;
4
+ export { Feedly };
5
+ export type { FeedlyInitOptions, FeedlyAPI } from './types';
@@ -0,0 +1,27 @@
1
+ import type { Anchor, Annotation, ShapeData } from '../types';
2
+ export declare function computeSelector(el: Element): string;
3
+ export type DeepHit = {
4
+ kind: 'parent';
5
+ el: Element;
6
+ } | {
7
+ kind: 'iframe';
8
+ iframe: HTMLIFrameElement;
9
+ inner: Element;
10
+ } | {
11
+ kind: 'blocked-iframe';
12
+ } | null;
13
+ export declare function findElementDeep(vx: number, vy: number): DeepHit;
14
+ export declare function findElementUnderViewportPoint(vx: number, vy: number): Element | null;
15
+ export type BuildAnchorResult = {
16
+ ok: true;
17
+ anchor: Anchor | null;
18
+ } | {
19
+ ok: false;
20
+ reason: 'blocked-iframe';
21
+ };
22
+ export declare function tryBuildAnchor(shape: ShapeData): BuildAnchorResult;
23
+ export declare function buildAnchor(shape: ShapeData): Anchor | null;
24
+ export declare function isAnchorBroken(anchor: Anchor | null): boolean;
25
+ export declare function resolveAnchoredShape(annotation: Annotation): ShapeData;
26
+ export declare function isAnchorFixed(anchor: Anchor | null): boolean;
27
+ export declare function anchorIframe(anchor: Anchor | null): HTMLIFrameElement | null;
@@ -0,0 +1,21 @@
1
+ export declare function annotationsTopic(projectId: string): string;
2
+ export declare function testersTopic(projectId: string): string;
3
+ export declare const BROADCAST_EVENT = "feedly";
4
+ export type AnnotationBroadcast = {
5
+ t: 'ann';
6
+ op: 'insert' | 'update';
7
+ id: string;
8
+ page_path: string;
9
+ } | {
10
+ t: 'ann';
11
+ op: 'delete';
12
+ id: string;
13
+ } | {
14
+ t: 'ann';
15
+ op: 'clear';
16
+ };
17
+ export type TesterBroadcast = {
18
+ t: 'tester';
19
+ op: 'update';
20
+ id: string;
21
+ };
@@ -0,0 +1,2 @@
1
+ export declare const TESTER_COLORS: readonly ["#E8593C", "#1D9E75", "#7F77DD", "#D4537E", "#378ADD", "#BA7517", "#639922", "#888780"];
2
+ export declare function pickColor(existingTesterCount: number): string;
@@ -0,0 +1,10 @@
1
+ import type { BBox } from '../types';
2
+ export declare function stopCapture(): void;
3
+ export declare function highFidelitySupported(): boolean;
4
+ export declare function bboxNeedsHighFidelity(bbox: BBox): boolean;
5
+ export type HighFidelityResult = {
6
+ dataUrl: string;
7
+ } | null;
8
+ export declare function captureViaScreenShare(bbox: BBox, opts?: {
9
+ allowScroll?: boolean;
10
+ }): Promise<HighFidelityResult>;
@@ -0,0 +1,16 @@
1
+ import type { Point, ShapeData } from '../types';
2
+ export declare function smoothPath(points: Point[]): string;
3
+ export declare function bboxFromPoints(points: Point[]): {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ };
9
+ export declare function bboxOf(shape: ShapeData): {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ };
15
+ export declare function anchorOf(shape: ShapeData): Point;
16
+ export declare function translateShape(shape: ShapeData, dx: number, dy: number): ShapeData;
@@ -0,0 +1,37 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ export declare const BUCKET = "feedback-snapshots";
3
+ type CaptureArgs = {
4
+ supabase: SupabaseClient;
5
+ projectId: string;
6
+ bbox: {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ };
12
+ shouldProceed?: () => boolean;
13
+ };
14
+ export type CaptureResult = {
15
+ path: string;
16
+ viewport: {
17
+ vw: number;
18
+ vh: number;
19
+ dpr: number;
20
+ };
21
+ capturedAt: string;
22
+ bbox: {
23
+ x: number;
24
+ y: number;
25
+ width: number;
26
+ height: number;
27
+ pads: {
28
+ l: number;
29
+ t: number;
30
+ r: number;
31
+ b: number;
32
+ };
33
+ };
34
+ };
35
+ export declare function captureAnnotationSnapshot(args: CaptureArgs): Promise<CaptureResult | null>;
36
+ export declare function snapshotPublicUrl(supabase: SupabaseClient, path: string): string;
37
+ export {};
@@ -0,0 +1,21 @@
1
+ type Args = {
2
+ /** id of the widget host element so we can detach + re-attach across swaps */
3
+ hostId: string;
4
+ };
5
+ export declare function installHistoryListeners(): void;
6
+ export declare function installSoftNavigation({ hostId }: Args): () => void;
7
+ export declare function uninstallSoftNavigation(): void;
8
+ /** Subscribe a listener to soft-navigation events. Returns an unsubscribe fn. */
9
+ export declare function onSoftNavigate(handler: () => void): () => void;
10
+ /**
11
+ * Programmatically navigate to a URL. Prefers soft-navigation (no full
12
+ * page reload, widget state preserved) when `installSoftNavigation` has
13
+ * been called; otherwise falls back to a real navigation.
14
+ *
15
+ * Used by Flow replay: when the dev picks a flow recorded on a different
16
+ * page, we take them there first.
17
+ */
18
+ export declare function navigateTo(url: string): Promise<void>;
19
+ /** Returns true when same-origin URL matches the current location's URL. */
20
+ export declare function urlsMatchSamePage(a: string, b: string): boolean;
21
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { StoredTesterIdentity } from '../types';
2
+ export declare function loadIdentity(projectId: string): StoredTesterIdentity | null;
3
+ export declare function saveIdentity(identity: StoredTesterIdentity): void;
4
+ export declare function clearIdentity(): void;
5
+ export declare function savePinIntent(projectId: string, annotationId: string): void;
6
+ export declare function consumePinIntent(projectId: string): string | null;
@@ -0,0 +1,10 @@
1
+ import { type SupabaseClient } from '@supabase/supabase-js';
2
+ export declare function getDeviceId(): string;
3
+ export declare function getSupabase(url: string, anonKey: string): SupabaseClient;
4
+ export declare function resetSupabase(): void;
5
+ export declare function sendMagicLink(supabase: SupabaseClient, email: string): Promise<{
6
+ ok: true;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ }>;
@@ -0,0 +1,3 @@
1
+ import type { TechDetails } from '../types';
2
+ export declare function captureTechDetails(): TechDetails;
3
+ export declare function generateDummyTicket(): string;
@@ -0,0 +1,194 @@
1
+ export type FeedlyInitOptions = {
2
+ projectId: string;
3
+ /** Override the Supabase URL (otherwise baked in at build time). */
4
+ supabaseUrl?: string;
5
+ /** Override the Supabase anon key (otherwise baked in at build time). */
6
+ supabaseAnonKey?: string;
7
+ /**
8
+ * Intercept same-origin link clicks and swap page content without a full
9
+ * reload, so the widget UI (F button, toolbar, comment list) stays mounted
10
+ * across navigations. Default false — only safe to enable on apps that
11
+ * don't already have their own SPA router (Next.js, React Router, etc.).
12
+ */
13
+ smoothNavigation?: boolean;
14
+ /**
15
+ * Production mode: render nothing for visitors who haven't been invited.
16
+ * The widget will only mount for devices that have either (a) opened a
17
+ * `?feedly_invite=<token>` URL at least once OR (b) already have a tester
18
+ * identity in localStorage from a prior redemption.
19
+ *
20
+ * Default false (every visitor on a session_active project sees the F —
21
+ * the right thing for closed alpha/dev). Set to true before going live.
22
+ */
23
+ requireInvite?: boolean;
24
+ /**
25
+ * z-index for the pins-only layer. Pins sit at this level: above the
26
+ * host page's normal content but below any positioned element with a
27
+ * higher z-index — modals, dropdowns, toasts, sticky headers, etc.
28
+ *
29
+ * Default `0`. This works for every modal library out there because
30
+ * they all use a positive z-index for their overlay (Tailwind UI = 50,
31
+ * Bootstrap = 1050, Material UI = 1300, Ant Design = 1000, Headless UI
32
+ * portals = 50, etc.). At z-index 0 the pins are above unstyled body
33
+ * content but below anything explicitly stacked above zero.
34
+ *
35
+ * Override only if you specifically want pins to render over certain
36
+ * positioned page content (e.g., a sticky header at z-10 that you'd
37
+ * rather pins float on top of).
38
+ *
39
+ * The rest of the widget UI (F button, toolbar, sticky notes, drawing
40
+ * SVG, spotlight) stays at the max-z-index UI layer and is always on
41
+ * top — including over modals.
42
+ */
43
+ pinsZIndex?: number;
44
+ };
45
+ export type FeedlyAPI = {
46
+ init: (opts: FeedlyInitOptions) => void;
47
+ destroy: () => void;
48
+ };
49
+ export type AnnotationType = 'circle' | 'arrow' | 'rectangle';
50
+ export type ShapeData = {
51
+ kind: 'circle';
52
+ path: string;
53
+ bbox: BBox;
54
+ } | {
55
+ kind: 'arrow';
56
+ from: Point;
57
+ to: Point;
58
+ } | {
59
+ kind: 'rectangle';
60
+ x: number;
61
+ y: number;
62
+ width: number;
63
+ height: number;
64
+ };
65
+ export type Point = {
66
+ x: number;
67
+ y: number;
68
+ };
69
+ export type BBox = {
70
+ x: number;
71
+ y: number;
72
+ width: number;
73
+ height: number;
74
+ };
75
+ export type Tester = {
76
+ id: string;
77
+ project_id: string;
78
+ name: string;
79
+ device_id: string;
80
+ color: string;
81
+ is_admin?: boolean;
82
+ email?: string | null;
83
+ auth_user_id?: string | null;
84
+ created_at?: string;
85
+ };
86
+ export type TesterRef = {
87
+ id: string;
88
+ name: string;
89
+ color: string;
90
+ };
91
+ export type Project = {
92
+ id: string;
93
+ name: string;
94
+ session_active: boolean;
95
+ };
96
+ export type Anchor = {
97
+ selector: string;
98
+ origin: Point;
99
+ iframeContent?: {
100
+ innerSelector: string;
101
+ };
102
+ };
103
+ export type AnnotationStatus = 'open' | 'fixed' | 'confirmed' | 'reopened';
104
+ export type TechDetails = {
105
+ user_agent: string;
106
+ platform: string;
107
+ language: string;
108
+ page_title: string;
109
+ viewport: {
110
+ width: number;
111
+ height: number;
112
+ };
113
+ screen: {
114
+ width: number;
115
+ height: number;
116
+ };
117
+ device_pixel_ratio: number;
118
+ browser?: {
119
+ name: string;
120
+ version: string;
121
+ };
122
+ os?: {
123
+ name: string;
124
+ version: string;
125
+ };
126
+ };
127
+ export type StatusHistoryEntry = {
128
+ from: AnnotationStatus;
129
+ to: AnnotationStatus;
130
+ by_tester_id: string;
131
+ by_tester_name: string;
132
+ by_tester_color: string;
133
+ at: string;
134
+ comment: string | null;
135
+ };
136
+ export type Annotation = {
137
+ id: string;
138
+ project_id: string;
139
+ tester_id: string;
140
+ page_url: string;
141
+ page_path: string;
142
+ type: AnnotationType;
143
+ shape_data: ShapeData;
144
+ comment: string | null;
145
+ created_at: string;
146
+ anchor: Anchor | null;
147
+ status: AnnotationStatus;
148
+ status_history: StatusHistoryEntry[];
149
+ tech_details: TechDetails | null;
150
+ jira_ticket: string | null;
151
+ note_offset: Point | null;
152
+ snapshot_path: string | null;
153
+ snapshot_bbox: (BBox & {
154
+ pads?: {
155
+ l: number;
156
+ t: number;
157
+ r: number;
158
+ b: number;
159
+ };
160
+ }) | null;
161
+ snapshot_viewport: {
162
+ vw: number;
163
+ vh: number;
164
+ dpr: number;
165
+ } | null;
166
+ snapshot_captured_at: string | null;
167
+ tester?: TesterRef;
168
+ };
169
+ export type StoredTesterIdentity = {
170
+ id: string;
171
+ name: string;
172
+ color: string;
173
+ projectId: string;
174
+ };
175
+ export type FlowStep = {
176
+ step: number;
177
+ page_url: string;
178
+ page_path: string;
179
+ action: 'click';
180
+ element_selector: string;
181
+ element_text: string;
182
+ ts: number;
183
+ note: string | null;
184
+ annotation: null;
185
+ };
186
+ export type Flow = {
187
+ id: string;
188
+ project_id: string;
189
+ tester_id: string;
190
+ name: string;
191
+ steps: FlowStep[];
192
+ created_at: string;
193
+ tester?: TesterRef;
194
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "feedly-widget",
3
+ "version": "0.1.0",
4
+ "description": "Visual feedback annotation widget. Drop into any web app and let testers annotate live.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/feedly-widget.cjs.js",
8
+ "module": "./dist/feedly-widget.es.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/feedly-widget.es.js",
14
+ "require": "./dist/feedly-widget.cjs.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/**/*.map",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "dev": "vite",
24
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.build.json",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "dependencies": {
28
+ "@supabase/supabase-js": "^2.45.0",
29
+ "modern-screenshot": "^4.7.0",
30
+ "react": "^18.3.1",
31
+ "react-dom": "^18.3.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/react": "^18.3.3",
35
+ "@types/react-dom": "^18.3.0",
36
+ "@vitejs/plugin-react": "^4.3.1",
37
+ "autoprefixer": "^10.4.19",
38
+ "postcss": "^8.4.39",
39
+ "tailwindcss": "^3.4.7",
40
+ "typescript": "^5.5.4",
41
+ "vite": "^5.3.5"
42
+ }
43
+ }