@ozvi/widget 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ozvi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,287 @@
1
- # Temporary Holding Version
1
+ # @ozvi/widget
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ozvi inside your app, MIT:
4
+
5
+ - `<ozvi-widget>`: the floating feedback widget (bug reports with masked context, ideas with voting), Preact in shadow DOM;
6
+ - `window.Ozvi`: a JavaScript API to open it from your own buttons, prefill it, identify the person and listen to events;
7
+ - `<ozvi-roadmap>` and `<ozvi-changelog>`: inline embeds (with a "What's new" badge);
8
+ - `@ozvi/widget`, `@ozvi/widget/react`, `@ozvi/widget/vue`: typed ESM wrappers.
9
+
10
+ ```html
11
+ <script src="https://feedback.example.com/widget.js" defer></script>
12
+ <ozvi-widget project="billiwo" token-url="/api/ozvi-token" app-version="2.14.0"></ozvi-widget>
13
+ ```
14
+
15
+ - Entry points: "Report a bug" (private, auto-context + masked screenshot), "Suggest an idea" (top ideas, search, voting
16
+ and a new idea with similar ideas shown while typing, all inside the widget) and "My reports" (the board in a new tab).
17
+ - Styled only through `--oz-*` variables from `@ozvi/theme` `toCss(theme, ":host")`; fonts only from the Ozvi host
18
+ (`/fonts/css`), never from Google.
19
+ - **Size**: `widget.js` stays within **20 kB gzip** (a test fails above it: `src/size.test.ts`; today 18.4 kB). Everything
20
+ else loads on demand and reuses the widget's Preact: `widget-shot.js` (screenshot, 9.6 kB), `widget-annotate.js`
21
+ (mark-up, 3.9 kB), `widget-ideas.js` (ideas, 4.9 kB), `widget-embeds.js` (roadmap/changelog, 4.9 kB).
22
+
23
+ ## JavaScript API
24
+
25
+ Everything the attributes do, plus control from your code. `window.Ozvi` exists once `widget.js` has run. To call it
26
+ earlier (from inline code, or before the `defer` script), paste the stub first; it queues calls and `widget.js` runs
27
+ them in order:
28
+
29
+ ```html
30
+ <script>(function(w,m){var o=w.Ozvi=w.Ozvi||function(){(o.q=o.q||[]).push(arguments)};o.q=o.q||[];m.forEach(function(k){o[k]=o[k]||function(){o.q.push([k].concat([].slice.call(arguments)))}})})(window,["init","open","close","toggle","prefill","identify","logout","setTraits","setLanguage","on","off","configure"])</script>
31
+ <script src="https://feedback.example.com/widget.js" defer></script>
32
+ ```
33
+
34
+ | Call | What it does |
35
+ |---|---|
36
+ | `Ozvi.open(tab?)` | `"menu"` (default), `"bug"` (screenshot + form), `"ideas"` (list with search and voting), `"idea"` (new idea form), `"mine"` (the person's reports on the board, new tab) |
37
+ | `Ozvi.close()`, `Ozvi.toggle(tab?)`, `Ozvi.isOpen()` | |
38
+ | `Ozvi.prefill({ title, body, kind })` | text for the next bug report (`kind: "bug"`, default) or idea (`"idea"`); `null` clears. Cleared after a successful send |
39
+ | `Ozvi.identify(token \| () => token \| { token, id })` | use this SSO token (or a function returning a fresh one) instead of `token-url`; `id` only keys the local "seen" state of the changelog |
40
+ | `Ozvi.logout()` | forget the identity and any cached token (call it when the person signs out of your app) |
41
+ | `Ozvi.setTraits({ plan: "Pro" })` | shown to the team with bug reports as "Page data (unverified)". Traits that matter (MRR, plan for sorting) belong in the **signed** token (`traits` in `signOzviToken`) |
42
+ | `Ozvi.setLanguage("en")` | `sk`, `cs`, `en`; switches the widget and the embeds at once |
43
+ | `Ozvi.on(event, fn)` → `unsubscribe`, `Ozvi.off(event, fn)` | see events below |
44
+ | `Ozvi.init({ project, tokenUrl, ... })` | adds an `<ozvi-widget>` with these options (attributes in camelCase) if there isn't one |
45
+ | `Ozvi.configure({...})` | same as `window.OzviConfig` (below) |
46
+ | `Ozvi.ready` | a Promise resolving to the API once `widget.js` has loaded; with the stub use `Ozvi("ready", fn)` |
47
+ | `Ozvi("open", "ideas")` | snippet-style call, works before and after load |
48
+
49
+ The element is a handle too: `document.querySelector("ozvi-widget").open("ideas")` (`open`, `close`, `toggle`, `prefill`).
50
+
51
+ **Events** (`Ozvi.on`, and as DOM events `ozvi:<name>` bubbling from `<ozvi-widget>`):
52
+
53
+ | Event | Detail |
54
+ |---|---|
55
+ | `open` | `{ tab }` |
56
+ | `close` | `{}` |
57
+ | `submit` | `{ kind: "bug" \| "idea", title }` before sending |
58
+ | `submitted` | `{ kind, number, url, pending }`: `pending` is `"approval"` or `"links"` when the post waits for the team's approval |
59
+ | `error` | `{ kind?: "bug" \| "idea" \| "vote", message }`; `message` is already in the widget's language |
60
+
61
+ ```js
62
+ Ozvi.on("submitted", ({ kind, number, url, pending }) => analytics.track("feedback_sent", { kind, number, pending: !!pending }));
63
+ document.querySelector("#help-menu .feedback").addEventListener("click", () => Ozvi.open("ideas"));
64
+ Ozvi.prefill({ kind: "bug", title: "Export failed", body: `Invoice ${invoice.id}` }); Ozvi.open("bug");
65
+ ```
66
+
67
+ Voting in the widget follows the board: simple or importance (1–3, the button cycles 0 → 1 → 2 → 3 → 0) and the vote
68
+ budget, all enforced by the server. Voting doesn't subscribe anyone to e-mails. Ideas from outside the team can wait
69
+ for approval; the widget says so and `submitted.pending` is set.
70
+
71
+ ## Inline roadmap and changelog
72
+
73
+ ```html
74
+ <ozvi-roadmap project="billiwo" limit="5"></ozvi-roadmap>
75
+ <ozvi-changelog project="billiwo" limit="3"></ozvi-changelog>
76
+ <ozvi-changelog project="billiwo" mode="badge" align="end"></ozvi-changelog>
77
+ ```
78
+
79
+ They need `widget.js` on the page (it defines the tags and loads `widget-embeds.js` when one appears). Shadow DOM,
80
+ the project theme, keyboard and screen-reader friendly, and they reflow down to 320 px (lanes stack under 640 px).
81
+
82
+ | Attribute | |
83
+ |---|---|
84
+ | `project` | required |
85
+ | `host` | Ozvi base URL (default: where `widget.js` came from) |
86
+ | `lang` | `sk` / `cs` / `en` (default: `<html lang>`, then the browser) |
87
+ | `heading` | own heading text, or `none` to hide it (it stays for screen readers) |
88
+ | `limit` | roadmap: cards per lane (default 5); changelog: entries (default 5, max 20) |
89
+ | `lanes` | roadmap: `planned,in_progress,shipped` (any subset) |
90
+ | `mode` | changelog: `list` (default) or `badge`: a "What's new" button with the unseen count and a popover |
91
+ | `align` | changelog badge: `end` opens the popover to the left (button at the right edge) |
92
+ | `token-url` / `token` | only for boards whose ideas aren't public |
93
+
94
+ **Unseen count**: entries newer than the last one the person has seen. Stored in `localStorage` only (per Ozvi host,
95
+ project and person: `identify({ id })` or the id inside the SSO token, else anonymous); nothing is sent to Ozvi. The inline
96
+ list counts as seen after it has been on screen for a moment, the badge when it is opened. `<ozvi-changelog>` fires
97
+ `ozvi:unseen` with `{ count }`, so you can put the number on your own menu item:
98
+
99
+ ```js
100
+ document.querySelector("ozvi-changelog").addEventListener("ozvi:unseen", (e) => (menuBadge.textContent = e.detail.count || ""));
101
+ ```
102
+
103
+ Public boards serve the roadmap and changelog to any site (read-only, no cookies, no token). Boards with private ideas
104
+ answer only with a token, and only to origins in the project's allowed origins.
105
+
106
+ ## npm: ESM, React, Vue
107
+
108
+ ```js
109
+ import { loadOzvi, ozvi } from "@ozvi/widget";
110
+ loadOzvi({ host: "https://feedback.example.com", project: "acme", tokenUrl: "/api/ozvi-token" });
111
+ ozvi.open("ideas"); // queued until widget.js has loaded; a no-op on the server
112
+ ```
113
+
114
+ ```tsx
115
+ "use client"; // Next.js App Router: the hooks and components are client components
116
+ import { OzviWidget, OzviChangelog, OzviRoadmap, useOzvi, useOzviEvent } from "@ozvi/widget/react";
117
+
118
+ export function Feedback() {
119
+ const ozvi = useOzvi();
120
+ useOzviEvent("submitted", (d) => console.log("sent", d.number));
121
+ return (
122
+ <>
123
+ <OzviWidget host="https://feedback.example.com" project="acme" tokenUrl="/api/ozvi-token" launcher="none" />
124
+ <button onClick={() => ozvi.open("ideas")}>Ideas</button>
125
+ <OzviChangelog host="https://feedback.example.com" project="acme" mode="badge" onUnseen={(n) => setBadge(n)} />
126
+ </>
127
+ );
128
+ }
129
+ ```
130
+
131
+ ```ts
132
+ // Vue 3 / Nuxt
133
+ import { OzviPlugin, OzviRoadmap, OzviChangelog, useOzvi, useOzviEvent } from "@ozvi/widget/vue";
134
+ app.use(OzviPlugin, { host: "https://feedback.example.com", project: "acme", tokenUrl: "/api/ozvi-token" });
135
+ // <OzviChangelog host="https://feedback.example.com" project="acme" :limit="3" @unseen="n => badge = n" />
136
+ // Using <ozvi-*> tags directly in templates: compilerOptions.isCustomElement = (tag) => tag.startsWith("ozvi-")
137
+ ```
138
+
139
+ Types for everything (`OzviApi`, `OzviEvents`, `OzviOptions`, …) come with the package.
140
+
141
+ **SSR / Next.js**: importing is safe on the server; nothing touches `window` until an effect runs, the components render
142
+ plain custom-element tags (hydration-safe, the shadow DOM attaches after `widget.js` loads), and `ozvi.*` calls are
143
+ no-ops on the server. Give the token endpoint a route handler that signs with `@ozvi/sdk`:
144
+
145
+ ```ts
146
+ // app/api/ozvi-token/route.ts
147
+ import { signOzviToken } from "@ozvi/sdk";
148
+ export async function GET() {
149
+ const user = await currentUser(); // your auth
150
+ if (!user) return new Response(null, { status: 401 });
151
+ return Response.json({ token: signOzviToken({ id: user.id, email: user.email, name: user.name }, process.env.OZVI_SSO_SECRET!) });
152
+ }
153
+ ```
154
+
155
+ The widget caches a token from `token-url` until a minute before it expires (10 minutes by default), so browsing ideas
156
+ and voting don't hit your endpoint on every click.
157
+
158
+ ## Strict CSP
159
+
160
+ The widget runs under a nonce-based CSP without `unsafe-inline` or `unsafe-eval`:
161
+
162
+ - give the `<script>` a nonce (`<script nonce="…" src=".../widget.js">`, or `loadOzvi({ nonce })` / `<OzviWidget nonce>`).
163
+ `widget.js` passes it on to every script it loads on demand and to every `<style>` it adds (inside its shadow roots and
164
+ the screenshot's temporary blur style); with `'strict-dynamic'` nothing else is needed for scripts;
165
+ - or set it explicitly: `window.OzviConfig = { nonce: "…" }`.
166
+
167
+ Directives the widget needs (replace the host with yours):
168
+
169
+ ```
170
+ script-src 'nonce-RANDOM' 'strict-dynamic' https://feedback.example.com;
171
+ style-src 'nonce-RANDOM' https://feedback.example.com;
172
+ font-src https://feedback.example.com;
173
+ connect-src https://feedback.example.com;
174
+ img-src 'self' blob: data: https://feedback.example.com;
175
+ ```
176
+
177
+ `style-src` lists the Ozvi host for the font stylesheet (`/fonts/css`); `img-src blob: data:` is for the screenshot
178
+ preview. Inline `style` attributes are never used in markup (only through CSSOM, which CSP allows).
179
+
180
+ ## What a bug report captures
181
+
182
+ Everything below is kept **in memory only**, is **masked in the browser before it is kept**, and leaves the page
183
+ **only when the person sends a bug report**. The reporter can untick "Attach the request log and recent steps"
184
+ before sending. The network log, steps and session links are visible **to the project team only** (not to the
185
+ reporter on the board, not in webhooks or GitHub issues); the Ozvi server masks and bounds them again.
186
+
187
+ | What | Detail | Never |
188
+ |---|---|---|
189
+ | Page | URL (masked), app version, browser, window size, language | |
190
+ | Screenshot | visible viewport without the widget; private data **covered with solid boxes before the image is made** (see [Screenshot privacy](#screenshot-privacy)); the reporter can mark it up, blur more (burned into the pixels) or remove it | form values, e-mails, phone numbers, IBANs, card numbers, tokens, `[data-ozvi-mask]` |
191
+ | Console errors | last 20, 300 characters each | |
192
+ | Network log | last 30 `fetch`/`XMLHttpRequest` calls: method, URL, status, duration, size, failed flag | request/response headers (so no `Authorization`, no `Cookie`); bodies unless you turn them on |
193
+ | Request bodies | only with `networkBodies: true`, only for **failed** requests, masked, max 2 000 characters each | bodies of successful requests |
194
+ | Steps | last 30: route changes, clicks on buttons/links/tabs by accessible name, form submits by form name | what anyone typed: input, textarea and select values are never read |
195
+ | Session links | Sentry replay, PostHog session, LogRocket session, when you pass them or allow detection | |
196
+
197
+ **Masking** (in `@ozvi/sdk/mask`, the same code runs in the browser and on the server):
198
+
199
+ - query/fragment parameters, JSON keys (at any depth) and form fields whose **name** matches
200
+ `token|secret|passw|pwd|key|auth|session|card|iban|cvv|cvc|otp|signature|cookie|csrf|xsrf|jwt|bearer|ssn|credential`
201
+ get the value `***`;
202
+ - in any value: JWTs, `Bearer`/`Basic` credentials, e-mail addresses, IBANs and card numbers (Luhn-checked) become `***`;
203
+ - `user:password@` is removed from URLs;
204
+ - clicks inside `[data-ozvi-mask]` are logged as `***`, and masked parts of a button's text are replaced by `***`.
205
+
206
+ Requests to Ozvi itself and to your `token-url` are never logged.
207
+
208
+ ## Screenshot privacy
209
+
210
+ The screenshot is drawn from a **copy** of the page that the widget edits first; the page the person
211
+ sees is never touched, and what is covered never becomes pixels (covered text is drawn transparent on a
212
+ solid box, covered images are not drawn). Nothing to set up for the common cases:
213
+
214
+ | Covered automatically | How |
215
+ |---|---|
216
+ | Values of every `input`, `textarea`, `select` and `contenteditable` | shown as `•••••` or a box; checkboxes and radios keep their state |
217
+ | E-mail addresses, phone numbers (9–15 digits), IBANs, card numbers (Luhn-checked), JWTs and `Bearer`/`Basic` credentials in any text | the match is boxed, the rest of the sentence stays readable |
218
+ | Everything inside `[data-ozvi-mask]` | text, images, canvas and video of the element, boxed |
219
+
220
+ Shapes can't find everything: **mark names, notes, diagnoses, amounts or anything else private** with
221
+ `data-ozvi-mask`:
222
+
223
+ ```html
224
+ <td data-ozvi-mask>Jana Kováčová</td>
225
+ <p data-ozvi-mask>{{ client.note }}</p>
226
+ ```
227
+
228
+ For apps where almost everything on screen is private (health, finance, HR), cover **all** text and
229
+ images and reveal only the parts that help the team, such as navigation and headings:
230
+
231
+ ```js
232
+ window.OzviConfig = { mask: "all" };
233
+ ```
234
+ ```html
235
+ <nav data-ozvi-unmask>…</nav>
236
+ <h1 data-ozvi-unmask>Invoice settings</h1>
237
+ ```
238
+
239
+ If the copy can't be masked (an unexpected error), the report goes **without** a screenshot, never with
240
+ an unmasked one. The reporter still sees the screenshot before sending and can remove it.
241
+
242
+ ## Configuration
243
+
244
+ Simple switches as attributes:
245
+
246
+ | Attribute | Effect |
247
+ |---|---|
248
+ | `network="off"` | no network log |
249
+ | `network="bodies"` | also keep masked bodies of failed requests |
250
+ | `actions="off"` | no step timeline |
251
+ | `links="auto"` | detect Sentry / PostHog / LogRocket session links from their globals |
252
+
253
+ Everything else via `window.OzviConfig` (set it **before** `widget.js` so the first requests are covered) or
254
+ `Ozvi.configure({...})` later:
255
+
256
+ ```js
257
+ window.OzviConfig = {
258
+ network: true, // false = no network log
259
+ networkBodies: false, // true = masked bodies of failed requests (max 2 000 chars)
260
+ actions: true, // false = no step timeline
261
+ // Last word on every logged request, after the built-in masking (which runs again afterwards).
262
+ // Return the entry (changed or not), or null to drop it.
263
+ maskRequest(entry) {
264
+ if (entry.url.includes("/internal/")) return null;
265
+ return { ...entry, url: entry.url.replace(/\/customers\/\d+/, "/customers/:id") };
266
+ },
267
+ // Session links shown to the team as buttons; a function is called when the report is sent.
268
+ links: {
269
+ sentry: () => `https://acme.sentry.io/replays/${Sentry.getReplay()?.getReplayId()}/`,
270
+ posthog: () => posthog.get_session_replay_url({ withTimestamp: true }),
271
+ logrocket: () => LogRocket.sessionURL,
272
+ },
273
+ detectLinks: false, // true = read them from window.Sentry / posthog / LogRocket yourself
274
+ sentryOrg: "acme", // needed for the detected Sentry replay URL (https://<org>.sentry.io/replays/<id>/)
275
+ mask: "auto", // "all" = screenshots cover all text and images except [data-ozvi-unmask]
276
+ };
277
+ ```
278
+
279
+ A `maskRequest` that throws drops the entry (it never leaks what it meant to hide). Only `http(s)` session links
280
+ are accepted.
281
+
282
+ ## Screenshot mark-up
283
+
284
+ "Mark up" on the screenshot opens the editor (`widget-annotate.js`, ~4 kB gzip, loaded on click): box, arrow,
285
+ highlight, blur. Keyboard: pick a tool, focus the image, **Enter** adds the shape in the middle, **arrows** move it,
286
+ **Shift+arrows** resize it, **Delete** removes it, **Ctrl/Cmd+Z** undoes, **Escape** or "Use as is" skips the mark-up.
287
+ The marked-up JPEG replaces the original, so blurred areas never leave the browser.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What the widget remembers so the team can reproduce a bug: the last requests (network log), the last
3
+ * user actions (route changes, clicks by accessible name, form submits) and console errors.
4
+ * Everything is masked BEFORE it is kept, lives only in memory, and leaves the page only with a bug report.
5
+ *
6
+ * Host app configuration (set before widget.js loads, or later via Ozvi.configure):
7
+ * window.OzviConfig = {
8
+ * network: false, // no network log
9
+ * networkBodies: true, // also keep bodies of FAILED requests (masked, max 2 000 chars)
10
+ * actions: false, // no action timeline
11
+ * maskRequest: (e) => e, // last word on every request: change it, or return null to drop it
12
+ * links: { sentry: () => url, posthog: url, logrocket: url },
13
+ * detectLinks: true, // read session links from Sentry/PostHog/LogRocket globals
14
+ * sentryOrg: "acme", // needed to build the Sentry replay URL
15
+ * }
16
+ * Attributes on <ozvi-widget> do the same for the simple cases: network="off|bodies", actions="off", links="auto".
17
+ */
18
+ export interface NetEntry {
19
+ at: number;
20
+ method: string;
21
+ url: string;
22
+ status: number;
23
+ ms: number;
24
+ failed: boolean;
25
+ size?: number;
26
+ type?: string;
27
+ error?: string;
28
+ req?: string;
29
+ res?: string;
30
+ }
31
+ export interface Action {
32
+ at: number;
33
+ kind: "nav" | "click" | "submit";
34
+ label: string;
35
+ role?: string;
36
+ }
37
+ export interface LogLine {
38
+ at: number;
39
+ text: string;
40
+ }
41
+ export type LinkKind = "sentry" | "posthog" | "logrocket";
42
+ export interface OzviConfig {
43
+ network?: boolean;
44
+ networkBodies?: boolean;
45
+ actions?: boolean;
46
+ maskRequest?: (e: NetEntry) => NetEntry | null | false | undefined | void;
47
+ links?: Partial<Record<LinkKind, string | (() => string | null | undefined)>>;
48
+ detectLinks?: boolean;
49
+ sentryOrg?: string;
50
+ }
51
+ export declare const MAX_NET = 30;
52
+ export declare const MAX_ACTIONS = 30;
53
+ export declare const MAX_ERRORS = 20;
54
+ export declare const BODY_LIMIT = 2000;
55
+ /** Fixed-size buffer: the oldest entry falls out. */
56
+ export declare function ring<T>(max: number): {
57
+ push(x: T): void;
58
+ list: () => T[];
59
+ clear(): void;
60
+ };
61
+ export declare const net: {
62
+ push(x: NetEntry): void;
63
+ list: () => NetEntry[];
64
+ clear(): void;
65
+ };
66
+ export declare const actions: {
67
+ push(x: Action): void;
68
+ list: () => Action[];
69
+ clear(): void;
70
+ };
71
+ export declare const errors: {
72
+ push(x: LogLine): void;
73
+ list: () => LogLine[];
74
+ clear(): void;
75
+ };
76
+ /** URL prefixes never logged: the Ozvi host and the host app's token endpoint (its response IS a token). */
77
+ export declare const ignored: string[];
78
+ /** Built-in masking, then the host's maskRequest hook, then masking again (the hook may add data). */
79
+ export declare function finish(e: NetEntry): void;
80
+ export declare function installNetwork(): void;
81
+ /** The accessible name of a clicked control, never an input's value (except button-like inputs, whose value IS the label). */
82
+ export declare function nameOf(el: Element): string;
83
+ export declare function installActions(): void;
84
+ export declare function installErrors(): void;
85
+ /** Session links the host set, plus (only when allowed) the ones its Sentry / PostHog / LogRocket expose. */
86
+ export declare function sessionLinks(): Partial<Record<LinkKind, string>>;
87
+ /** What goes into a bug report's context; respects the opt-outs at the moment of sending too. */
88
+ export declare function snapshot(): {
89
+ network: NetEntry[];
90
+ actions: Action[];
91
+ errors: LogLine[];
92
+ links: Partial<Record<LinkKind, string>>;
93
+ };
94
+ export declare function install(): void;
@@ -0,0 +1,67 @@
1
+ // src/loader.ts
2
+ var METHODS = ["init", "open", "close", "toggle", "isOpen", "prefill", "identify", "logout", "setTraits", "setLanguage", "on", "off", "configure"];
3
+ var inBrowser = () => typeof window !== "undefined" && typeof document !== "undefined";
4
+ function getOzvi() {
5
+ if (!inBrowser()) return null;
6
+ const w = window;
7
+ if (!w.Ozvi) {
8
+ const stub = function(...a) {
9
+ stub.q.push(a);
10
+ };
11
+ stub.q = [];
12
+ for (const m of METHODS) stub[m] = (...a) => {
13
+ stub.q.push([m, ...a]);
14
+ };
15
+ stub.ready = new Promise((res) => stub.q.push(["ready", res]));
16
+ w.Ozvi = stub;
17
+ }
18
+ return w.Ozvi;
19
+ }
20
+ var loading = null;
21
+ function loadOzvi(opts = {}) {
22
+ if (!inBrowser()) return Promise.reject(new Error("loadOzvi() runs in the browser; call it from an effect"));
23
+ const w = window;
24
+ const { src, nonce, config, ...init } = opts;
25
+ if (config || nonce) w.OzviConfig = { ...w.OzviConfig, ...config, ...nonce && { nonce } };
26
+ const api = getOzvi();
27
+ if (init.project) api.init(init);
28
+ if (!loading) {
29
+ const url = src ?? `${(init.host ?? "").replace(/\/$/, "")}/widget.js`;
30
+ loading = new Promise((res, rej) => {
31
+ const existing = document.querySelector(`script[src="${url}"]`);
32
+ if (existing && window.Ozvi && "version" in window.Ozvi) return res();
33
+ const s = existing ?? document.createElement("script");
34
+ s.addEventListener("load", () => res());
35
+ s.addEventListener("error", () => {
36
+ loading = null;
37
+ rej(new Error(`Ozvi widget.js could not load from ${url}`));
38
+ });
39
+ if (!existing) {
40
+ s.src = url;
41
+ s.async = true;
42
+ if (nonce) s.nonce = nonce;
43
+ document.head.appendChild(s);
44
+ }
45
+ }).then(() => window.Ozvi.ready);
46
+ }
47
+ return loading;
48
+ }
49
+ var ozvi = Object.fromEntries(
50
+ METHODS.map((m) => [m, (...a) => {
51
+ const api = getOzvi();
52
+ if (!api) return m === "on" ? () => {
53
+ } : m === "isOpen" ? false : void 0;
54
+ if (m === "on" && !("version" in api)) {
55
+ api.on(...a);
56
+ return () => getOzvi().off(...a);
57
+ }
58
+ if (m === "isOpen" && !("version" in api)) return false;
59
+ return api[m](...a);
60
+ }])
61
+ );
62
+
63
+ export {
64
+ getOzvi,
65
+ loadOzvi,
66
+ ozvi
67
+ };
@@ -0,0 +1,33 @@
1
+ import type { OzviApi, OzviConfig, OzviOptions } from "./types";
2
+ /**
3
+ * @ozvi/widget — plain ESM entry. Loads widget.js from your Ozvi host once and gives you the API.
4
+ * Safe to import during server rendering: nothing touches `window` until you call a function in the browser,
5
+ * and on the server every call is a no-op.
6
+ *
7
+ * import { loadOzvi, ozvi } from "@ozvi/widget";
8
+ * loadOzvi({ host: "https://feedback.example.com", project: "acme", tokenUrl: "/api/ozvi-token" });
9
+ * ozvi.open("ideas"); // queued until widget.js has loaded
10
+ */
11
+ export type * from "./types";
12
+ export interface LoadOptions extends Partial<OzviOptions> {
13
+ /** URL of widget.js; default `${host}/widget.js`. */
14
+ src?: string;
15
+ /** CSP nonce for the <script> (and everything the widget adds after it). */
16
+ nonce?: string;
17
+ /** window.OzviConfig (network log, masking hooks, session links), applied before widget.js runs. */
18
+ config?: OzviConfig;
19
+ }
20
+ /** window.Ozvi, or the snippet stub that queues calls until widget.js replaces it. */
21
+ export declare function getOzvi(): OzviApi | null;
22
+ /**
23
+ * Adds widget.js (once) and, with `project`, an <ozvi-widget> (Ozvi.init). Resolves with window.Ozvi when ready.
24
+ * Rejects on the server and when the script cannot load (CSP, network).
25
+ */
26
+ export declare function loadOzvi(opts?: LoadOptions): Promise<OzviApi>;
27
+ /**
28
+ * The API without waiting: every call goes to window.Ozvi (queued before widget.js loads, no-op on the server).
29
+ * `on` returns an unsubscribe function in the browser.
30
+ */
31
+ export declare const ozvi: Omit<OzviApi, "ready" | "version" | "isOpen"> & {
32
+ isOpen(): boolean;
33
+ };
package/dist/loader.js ADDED
@@ -0,0 +1,10 @@
1
+ import {
2
+ getOzvi,
3
+ loadOzvi,
4
+ ozvi
5
+ } from "./chunk-O2YKRMB5.js";
6
+ export {
7
+ getOzvi,
8
+ loadOzvi,
9
+ ozvi
10
+ };
@@ -0,0 +1,44 @@
1
+ import { type ReactElement } from "react";
2
+ import { type LoadOptions } from "./loader";
3
+ import type { OzviEventName, OzviListener, OzviOptions } from "./types";
4
+ /**
5
+ * @ozvi/widget/react — hook and components. Works with SSR (Next.js App Router and Pages): nothing runs on the
6
+ * server except rendering the custom element tags; widget.js loads in an effect. Mark files that use the hooks
7
+ * with "use client" in the App Router.
8
+ *
9
+ * <OzviWidget host="https://feedback.example.com" project="acme" tokenUrl="/api/ozvi-token" />
10
+ * const ozvi = useOzvi(); ozvi.open("ideas");
11
+ * <OzviChangelog host="https://feedback.example.com" project="acme" limit={3} />
12
+ */
13
+ export * from "./loader";
14
+ /** The API (stable object; calls before widget.js loads are queued). */
15
+ export declare function useOzvi(): Omit<import("./types").OzviApi, "isOpen" | "ready" | "version"> & {
16
+ isOpen(): boolean;
17
+ };
18
+ /** Subscribes to a widget event for the lifetime of the component. */
19
+ export declare function useOzviEvent<E extends OzviEventName>(event: E, handler: OzviListener<E>): void;
20
+ export type OzviWidgetProps = OzviOptions & Pick<LoadOptions, "src" | "nonce" | "config">;
21
+ /** The floating widget: renders <ozvi-widget> and loads widget.js once. */
22
+ export declare function OzviWidget({ src, nonce, config, ...opts }: OzviWidgetProps): ReactElement;
23
+ export interface OzviEmbedProps {
24
+ project: string;
25
+ host?: string;
26
+ lang?: "sk" | "cs" | "en";
27
+ heading?: string;
28
+ tokenUrl?: string;
29
+ src?: string;
30
+ nonce?: string;
31
+ className?: string;
32
+ }
33
+ /** Inline roadmap: planned / in progress / shipped, `limit` cards per lane. */
34
+ export declare function OzviRoadmap(props: OzviEmbedProps & {
35
+ limit?: number;
36
+ lanes?: string;
37
+ }): ReactElement;
38
+ /** Inline changelog (latest `limit` entries) or, with mode="badge", a "What's new" button with the unseen count. */
39
+ export declare function OzviChangelog({ onUnseen, ...props }: OzviEmbedProps & {
40
+ limit?: number;
41
+ mode?: "list" | "badge";
42
+ align?: "start" | "end";
43
+ onUnseen?: (count: number) => void;
44
+ }): ReactElement;
package/dist/react.js ADDED
@@ -0,0 +1,55 @@
1
+ import {
2
+ getOzvi,
3
+ loadOzvi,
4
+ ozvi
5
+ } from "./chunk-O2YKRMB5.js";
6
+
7
+ // src/react.ts
8
+ import { createElement, useEffect, useRef } from "react";
9
+ var kebab = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null && v !== "").map(([k, v]) => [k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`), String(v)]));
10
+ function useOzvi() {
11
+ return ozvi;
12
+ }
13
+ function useOzviEvent(event, handler) {
14
+ const ref = useRef(handler);
15
+ ref.current = handler;
16
+ useEffect(() => ozvi.on(event, ((d) => ref.current(d))), [event]);
17
+ }
18
+ function OzviWidget({ src, nonce, config, ...opts }) {
19
+ useEffect(() => {
20
+ loadOzvi({ src, nonce, config, host: opts.host }).catch((e) => console.warn(e));
21
+ }, [src, opts.host]);
22
+ return createElement("ozvi-widget", { ...kebab(opts), suppressHydrationWarning: true });
23
+ }
24
+ function embed(tag, props, ref) {
25
+ const { src, nonce, className, ...rest } = props;
26
+ useEffect(() => {
27
+ loadOzvi({ src, nonce, host: rest.host }).catch((e) => console.warn(e));
28
+ }, [src, rest.host]);
29
+ return createElement(tag, { ...kebab(rest), class: className, ref, suppressHydrationWarning: true });
30
+ }
31
+ function OzviRoadmap(props) {
32
+ return embed("ozvi-roadmap", props);
33
+ }
34
+ function OzviChangelog({ onUnseen, ...props }) {
35
+ const cb = useRef(onUnseen);
36
+ cb.current = onUnseen;
37
+ const el = useRef(null);
38
+ useEffect(() => {
39
+ const node = el.current;
40
+ const h = (e) => cb.current?.(e.detail.count);
41
+ node?.addEventListener("ozvi:unseen", h);
42
+ return () => node?.removeEventListener("ozvi:unseen", h);
43
+ }, []);
44
+ return embed("ozvi-changelog", props, el);
45
+ }
46
+ export {
47
+ OzviChangelog,
48
+ OzviRoadmap,
49
+ OzviWidget,
50
+ getOzvi,
51
+ loadOzvi,
52
+ ozvi,
53
+ useOzvi,
54
+ useOzviEvent
55
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Public types of the Ozvi widget JavaScript API (window.Ozvi, the <ozvi-widget> element, @ozvi/widget,
3
+ * @ozvi/widget/react, @ozvi/widget/vue). Type-only: nothing here ends up in widget.js.
4
+ */
5
+ export interface NetEntry {
6
+ at: number;
7
+ method: string;
8
+ url: string;
9
+ status: number;
10
+ ms: number;
11
+ failed: boolean;
12
+ size?: number;
13
+ type?: string;
14
+ error?: string;
15
+ req?: string;
16
+ res?: string;
17
+ }
18
+ export type LinkKind = "sentry" | "posthog" | "logrocket";
19
+ /** window.OzviConfig / Ozvi.configure(): what bug reports capture (README, "Configuration"). */
20
+ export interface OzviConfig {
21
+ network?: boolean;
22
+ networkBodies?: boolean;
23
+ actions?: boolean;
24
+ maskRequest?: (e: NetEntry) => NetEntry | null | false | undefined | void;
25
+ links?: Partial<Record<LinkKind, string | (() => string | null | undefined)>>;
26
+ detectLinks?: boolean;
27
+ sentryOrg?: string;
28
+ /**
29
+ * Screenshot privacy. "auto" (default): form values, e-mails, phone numbers, IBANs, card numbers, tokens and
30
+ * [data-ozvi-mask] are covered. "all": every text and image is covered except inside [data-ozvi-unmask].
31
+ */
32
+ mask?: "auto" | "all";
33
+ /** CSP nonce for scripts and styles the widget adds (default: the nonce of the widget.js <script>). */
34
+ nonce?: string;
35
+ }
36
+ export type OzviLang = "sk" | "cs" | "en" | "pl" | "hu" | "de";
37
+ /** Which part of the widget to open. "mine" opens the person's reports on the board in a new tab. */
38
+ export type OzviTab = "menu" | "bug" | "ideas" | "idea" | "mine";
39
+ export interface OzviPrefill {
40
+ title?: string;
41
+ body?: string;
42
+ /** Which form the text goes to; default "bug". */
43
+ kind?: "bug" | "idea";
44
+ }
45
+ /** Something that returns a fresh signOzviToken() value from your backend. */
46
+ export type OzviTokenSource = string | (() => string | Promise<string>);
47
+ export interface OzviIdentity {
48
+ /** A signOzviToken() value or a function returning one. Without it the widget asks `token-url`. */
49
+ token?: OzviTokenSource;
50
+ /** Your user id; only used to remember, per person, which changelog entries they have seen. */
51
+ id?: string;
52
+ }
53
+ /** Shown to the team with bug reports as "Page data (unverified)". Signed traits belong in the SSO token. */
54
+ export type OzviTraits = Record<string, string | number | boolean | null | undefined>;
55
+ export interface OzviEvents {
56
+ open: {
57
+ tab: OzviTab;
58
+ };
59
+ close: Record<string, never>;
60
+ submit: {
61
+ kind: "bug" | "idea";
62
+ title: string;
63
+ };
64
+ /** The report or idea was saved. `pending` is set when it waits for the team's approval. */
65
+ submitted: {
66
+ kind: "bug" | "idea";
67
+ number: number;
68
+ url: string;
69
+ pending: string | null;
70
+ };
71
+ error: {
72
+ kind?: "bug" | "idea" | "vote";
73
+ message: string;
74
+ };
75
+ }
76
+ export type OzviEventName = keyof OzviEvents;
77
+ export type OzviListener<E extends OzviEventName> = (detail: OzviEvents[E]) => void;
78
+ /** What init() / loadOzvi() / <OzviWidget> accept: the <ozvi-widget> attributes in camelCase. */
79
+ export interface OzviOptions {
80
+ project: string;
81
+ /** Ozvi base URL; defaults to where widget.js was loaded from. */
82
+ host?: string;
83
+ /** Same-origin endpoint of your app returning {"token": "..."} (preferred: always fresh). */
84
+ tokenUrl?: string;
85
+ token?: string;
86
+ appVersion?: string;
87
+ /** "none" hides the floating button; open it with Ozvi.open(). */
88
+ launcher?: "none" | "button";
89
+ lang?: OzviLang;
90
+ label?: string;
91
+ network?: "off" | "bodies";
92
+ actions?: "off";
93
+ links?: "auto";
94
+ }
95
+ export interface OzviApi {
96
+ /** Snippet-style call: Ozvi("open", "ideas"). */
97
+ (method: string, ...args: unknown[]): unknown;
98
+ readonly version: string;
99
+ /** Resolves once widget.js has loaded and calls queued before that have run. */
100
+ readonly ready: Promise<OzviApi>;
101
+ /** Adds an <ozvi-widget> to the page (once) and returns it. */
102
+ init(options: OzviOptions): HTMLElement;
103
+ open(tab?: OzviTab): void;
104
+ close(): void;
105
+ toggle(tab?: OzviTab): void;
106
+ isOpen(): boolean;
107
+ /** Text for the next bug report or idea form; null clears it. */
108
+ prefill(p: OzviPrefill | null): void;
109
+ identify(who: OzviTokenSource | OzviIdentity): void;
110
+ /** Forgets the identity and any cached token (call it when the person signs out of your app). */
111
+ logout(): void;
112
+ setTraits(traits: OzviTraits): void;
113
+ setLanguage(lang: OzviLang): void;
114
+ on<E extends OzviEventName>(event: E, fn: OzviListener<E>): () => void;
115
+ off<E extends OzviEventName>(event: E, fn: OzviListener<E>): void;
116
+ configure(c: OzviConfig): void;
117
+ }
package/dist/vue.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { type App, type PropType } from "vue";
2
+ import { type LoadOptions } from "./loader";
3
+ import type { OzviEventName, OzviListener } from "./types";
4
+ /**
5
+ * @ozvi/widget/vue — plugin, composables and components. SSR-safe (Nuxt): widget.js loads only in the browser.
6
+ *
7
+ * app.use(OzviPlugin, { host: "https://feedback.example.com", project: "acme", tokenUrl: "/api/ozvi-token" });
8
+ * const ozvi = useOzvi(); ozvi.open("ideas");
9
+ * <OzviChangelog host="https://feedback.example.com" project="acme" :limit="3" />
10
+ *
11
+ * Using the <ozvi-*> tags directly in templates? Tell Vue they are custom elements:
12
+ * compilerOptions.isCustomElement = (tag) => tag.startsWith("ozvi-").
13
+ */
14
+ export * from "./loader";
15
+ /** app.use(OzviPlugin, options): loads widget.js and adds the floating widget (when `project` is given). */
16
+ export declare const OzviPlugin: {
17
+ install(app: App, opts?: LoadOptions): void;
18
+ };
19
+ export declare const useOzvi: () => Omit<import("./types").OzviApi, "isOpen" | "ready" | "version"> & {
20
+ isOpen(): boolean;
21
+ };
22
+ /** Subscribes to a widget event while the calling component is mounted. */
23
+ export declare function useOzviEvent<E extends OzviEventName>(event: E, handler: OzviListener<E>): void;
24
+ /** Inline roadmap: planned / in progress / shipped. */
25
+ export declare const OzviRoadmap: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
26
+ project: {
27
+ type: StringConstructor;
28
+ required: boolean;
29
+ };
30
+ host: StringConstructor;
31
+ lang: StringConstructor;
32
+ heading: StringConstructor;
33
+ tokenUrl: StringConstructor;
34
+ src: StringConstructor;
35
+ nonce: StringConstructor;
36
+ limit: PropType<number | string>;
37
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
38
+ [key: string]: any;
39
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, "unseen"[], "unseen", import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
40
+ project: {
41
+ type: StringConstructor;
42
+ required: boolean;
43
+ };
44
+ host: StringConstructor;
45
+ lang: StringConstructor;
46
+ heading: StringConstructor;
47
+ tokenUrl: StringConstructor;
48
+ src: StringConstructor;
49
+ nonce: StringConstructor;
50
+ limit: PropType<number | string>;
51
+ }>> & Readonly<{
52
+ onUnseen?: ((...args: any[]) => any) | undefined;
53
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
54
+ /** Inline changelog or, with mode="badge", a "What's new" button with the unseen count (emits `unseen`). */
55
+ export declare const OzviChangelog: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
56
+ project: {
57
+ type: StringConstructor;
58
+ required: boolean;
59
+ };
60
+ host: StringConstructor;
61
+ lang: StringConstructor;
62
+ heading: StringConstructor;
63
+ tokenUrl: StringConstructor;
64
+ src: StringConstructor;
65
+ nonce: StringConstructor;
66
+ limit: PropType<number | string>;
67
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
68
+ [key: string]: any;
69
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, "unseen"[], "unseen", import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
70
+ project: {
71
+ type: StringConstructor;
72
+ required: boolean;
73
+ };
74
+ host: StringConstructor;
75
+ lang: StringConstructor;
76
+ heading: StringConstructor;
77
+ tokenUrl: StringConstructor;
78
+ src: StringConstructor;
79
+ nonce: StringConstructor;
80
+ limit: PropType<number | string>;
81
+ }>> & Readonly<{
82
+ onUnseen?: ((...args: any[]) => any) | undefined;
83
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
package/dist/vue.js ADDED
@@ -0,0 +1,61 @@
1
+ import {
2
+ getOzvi,
3
+ loadOzvi,
4
+ ozvi
5
+ } from "./chunk-O2YKRMB5.js";
6
+
7
+ // src/vue.ts
8
+ import { defineComponent, h, onBeforeUnmount, onMounted, ref } from "vue";
9
+ var OzviPlugin = {
10
+ install(app, opts = {}) {
11
+ app.config.globalProperties.$ozvi = ozvi;
12
+ if (typeof window !== "undefined") loadOzvi(opts).catch((e) => console.warn(e));
13
+ }
14
+ };
15
+ var useOzvi = () => ozvi;
16
+ function useOzviEvent(event, handler) {
17
+ let off;
18
+ onMounted(() => {
19
+ off = ozvi.on(event, handler);
20
+ });
21
+ onBeforeUnmount(() => off?.());
22
+ }
23
+ var common = {
24
+ project: { type: String, required: true },
25
+ host: String,
26
+ lang: String,
27
+ heading: String,
28
+ tokenUrl: String,
29
+ src: String,
30
+ nonce: String,
31
+ limit: [Number, String]
32
+ };
33
+ var attrs = (p) => Object.fromEntries(Object.entries(p).filter(([k, v]) => v != null && v !== "" && k !== "src" && k !== "nonce").map(([k, v]) => [k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`), String(v)]));
34
+ function embed(tag, extra) {
35
+ return defineComponent({
36
+ props: { ...common, ...extra },
37
+ emits: ["unseen"],
38
+ setup(props, { emit }) {
39
+ const el = ref(null);
40
+ const onUnseen = (e) => emit("unseen", e.detail.count);
41
+ onMounted(() => {
42
+ loadOzvi({ src: props.src, nonce: props.nonce, host: props.host }).catch((e) => console.warn(e));
43
+ el.value?.addEventListener("ozvi:unseen", onUnseen);
44
+ });
45
+ onBeforeUnmount(() => el.value?.removeEventListener("ozvi:unseen", onUnseen));
46
+ return () => h(tag, { ...attrs(props), ref: el });
47
+ }
48
+ });
49
+ }
50
+ var OzviRoadmap = embed("ozvi-roadmap", { lanes: String });
51
+ var OzviChangelog = embed("ozvi-changelog", { mode: String, align: String });
52
+ export {
53
+ OzviChangelog,
54
+ OzviPlugin,
55
+ OzviRoadmap,
56
+ getOzvi,
57
+ loadOzvi,
58
+ ozvi,
59
+ useOzvi,
60
+ useOzviEvent
61
+ };
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@ozvi/widget",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "description": "Ozvi feedback widget for your app: bug reports with masked screenshots, ideas and voting, roadmap and changelog embeds. Plain ESM, React and Vue. MIT.",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/loader.d.ts",
11
+ "import": "./dist/loader.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react.d.ts",
15
+ "import": "./dist/react.js"
16
+ },
17
+ "./vue": {
18
+ "types": "./dist/vue.d.ts",
19
+ "import": "./dist/vue.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "peerDependencies": {
28
+ "react": ">=18",
29
+ "vue": ">=3.3"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "react": {
33
+ "optional": true
34
+ },
35
+ "vue": {
36
+ "optional": true
37
+ }
38
+ },
39
+ "devDependencies": {
40
+ "@types/react": "19.3.0",
41
+ "esbuild": "^0.28.2",
42
+ "happy-dom": "20.0.10",
43
+ "modern-screenshot": "^4.7.0",
44
+ "preact": "^10.29.8",
45
+ "react": "19.3.1",
46
+ "vue": "3.5.45"
47
+ },
48
+ "homepage": "https://ozvi.io/docs/widget",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/xzamiska/ozvi.git",
52
+ "directory": "packages/widget"
53
+ },
54
+ "keywords": [
55
+ "feedback",
56
+ "bug-report",
57
+ "widget",
58
+ "roadmap",
59
+ "changelog",
60
+ "ozvi"
61
+ ],
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "scripts": {
66
+ "build": "node build.mjs",
67
+ "typecheck": "tsc -p .",
68
+ "test": "vitest run"
69
+ }
6
70
  }