notant
v0.9.1
Published
Embeddable website feedback and issue reporting. Screenshot capture, annotation, and automatic diagnostics for React/Ionic apps.
Maintainers
Readme
Notant
Embeddable website feedback and issue reporting for React/Ionic apps.
Users capture a region of the page, annotate it with pen/text/shapes, and file a report. Notant attaches 19 diagnostic fields automatically and posts everything to a backend you configure.
Status: verified end-to-end against a live backend. Published to npm.
Licence: PolyForm Noncommercial 1.0.0 — free for noncommercial use. Commercial use requires a licence from Loved Digital Consulting.
Install
npm install notantPeer dependencies: react (18 or 19), react-dom, @ionic/react (8), ionicons (7 or 8).
Capture uses modern-screenshot — the one runtime dependency.
Use
import { Notant, createHttpTransport } from 'notant';
import 'notant/styles.css';
<Notant
enabled={isAuthenticated}
config={{
transport: createHttpTransport({
endpoint: 'https://<project>.supabase.co/functions/v1/ld-crm/public/issue-forms/<api-key>',
}),
siteId: 'hwt',
appVersion: __APP_VERSION__,
getUser: () => ({ email: user?.email, id: user?.id }),
getContext: () => ({ scope: activeScope, companyId: viewCompanyId }),
}}
/>;Mount it once, near the root, inside <IonApp>.
Configuration
| Option | Type | Notes |
| --------------- | ------------------------------- | ------------------------------------------------------------------------ |
| transport | IssueTransport | Required. Where reports go. |
| siteId | string | Identifies this site in the payload. |
| appVersion | string | Surfaced in diagnostics. |
| getUser | () => { email?, id? } | Called at submit time, so it always reflects the current session. |
| getContext | () => Record<string, unknown> | Arbitrary host context merged into the report. |
| categories | { value, label }[] | Defaults to bug / feature / question / general. |
| position | NotantPosition | FAB placement — see below. Defaults to bottom-right, 20px in. |
| appearance | NotantAppearance | FAB icons and colours, per button — see below. |
| privacyNotice | string \| false | Notice shown beside the capture before sending. Defaults on — see below. |
| shortcut | string \| false | Key that captures the viewport instantly. Defaults to mod+shift+s. |
Keyboard shortcut
Press Ctrl/Cmd+Shift+S and the viewport is captured as it stands — no area selector, no click — straight to the annotation form.
That exists because the FAB cannot photograph the thing you most often need a photograph of. A dropdown closes on blur, a popover closes on outside-click, an alert takes the click itself: reaching for the launcher destroys the state you were trying to report. A key press moves no focus, so nothing is dismissed.
shortcut: 'alt+shift+c'; // any combination
shortcut: false; // bind nothingmod resolves per platform — Command on macOS, Control elsewhere. ctrl, meta/cmd, alt/option and shift are literal. Modifiers are matched exactly, so Ctrl+Alt+Shift+S will not trigger a Ctrl+Shift+S binding — that combination belongs to whoever bound it. A binding with no modifier at all ('f2', '/') is suppressed while the user is typing in a field; modifier combinations always fire. An unparseable string binds nothing and warns on the console.
The default is not collision-free — Cmd+Shift+S is Save As in Safari — which is why the handler calls preventDefault, and why this is configurable. Set your own if it clashes with a binding of yours.
One limitation is worth stating plainly: a native <select> cannot be captured. The OS draws that popup, not the page, so nothing in the DOM can see it. Ionic's own overlays — ion-modal, ion-popover, ion-alert, ion-action-sheet — capture fine.
Privacy notice
A capture takes whatever is on the page, which on an authenticated app routinely means someone else's personal data. The reporter is the only person in the loop who can see that and act on it, so a notice is shown beside the screenshot by default:
Everything visible in this screenshot is sent with your report. Check for personal or sensitive information before submitting.
privacyNotice: 'Tarkista henkilötiedot ennen lähettämistä.'; // reword
privacyNotice: false; // remove entirelyDEFAULT_PRIVACY_NOTICE is exported, so you can append rather than replace:
import { DEFAULT_PRIVACY_NOTICE } from 'notant';
privacyNotice: `${DEFAULT_PRIVACY_NOTICE} Screenshots are kept for 90 days.`;Setting false submits captures with no warning at all. That's a deliberate choice, not a default.
Placement
Host apps often already have a FAB. Notant can sit on any corner or edge:
position: {
vertical: 'top' | 'middle' | 'bottom', // default 'bottom'
horizontal: 'left' | 'center' | 'right', // default 'right'
offset: 20, // px or any CSS length
// or { x, y } per axis
}The action list opens away from the nearest edge, so it never expands off-screen. Offsets apply only to edges the FAB is anchored to — a centred axis is positioned by Ionic and an offset there would push it off-centre.
Appearance
The launcher sits on top of your product, so it should look like it belongs there. A chat bubble is the wrong affordance on an app where reports mean bugs.
import { bug, cameraReverse } from 'ionicons/icons';
appearance: {
size: 'lg', // sm | md | lg
trigger: { icon: bug, color: '#111827', iconColor: '#fbbf24' },
capture: { icon: cameraReverse, color: 'danger' },
blank: { color: 'rebeccapurple', iconColor: '#fff' },
}size applies to all three buttons and scales the icon with them — sm 40px,
md 56px, lg 72px. Leaving it unset is not the same as 'md': unset
emits no sizing at all, so a host that has restyled the button keeps its own.
Three buttons, each optional: trigger is the launcher, capture opens area
selection, blank reports without a capture. Any field you leave out keeps its
default.
color takes either a built-in Ionic palette name (primary, secondary,
tertiary, success, warning, danger, light, medium, dark) or any
CSS colour. The two vocabularies cannot collide — none of the nine names is a
valid CSS colour — so there is no ambiguity and no second field to learn.
Passing an Ionic name routes it to the color prop, so your theme (including
dark mode and contrast variants) still applies. A CSS colour goes to
--background instead, because color would silently ignore it.
For a custom Ionic palette colour you defined yourself, pass the variable
rather than the name — color: 'var(--ion-color-brand)'. A bare 'brand' would
be treated as a CSS colour, and an invalid one.
Icons are ionicons exports, which you already have
as a peer dependency. Anything IonIcon accepts works, including a data URI.
getUser and getContext are the seam that keeps Notant host-agnostic — there is no auth library, no API client, and no framework assumption baked in.
Colour picker
NotantColorPicker is exported on its own. It is a colour control that happens
to be used by the annotation tools — nothing in it is specific to them.
import { NotantColorPicker, DEFAULT_SWATCHES } from 'notant';
<NotantColorPicker value={colour} onChange={setColour} label="Pen colour" />;| Prop | Type | Notes |
| ------------- | --------------------- | ----------------------------------------- |
| value | string | Any hex form; compared after normalising. |
| onChange | (v: string) => void | Always receives #rrggbb. |
| swatches | readonly string[] | Defaults to DEFAULT_SWATCHES (12). |
| label | string | Accessible name, e.g. "Pen colour". |
| allowCustom | boolean | Set false to leave only the swatches. |
The default palette is picked for legibility on top of a screenshot, where the background is arbitrary — saturated mid-tones rather than pastels, and both neutral extremes, since white is the only thing that reads on a dark capture and black the only thing that reads on a light one.
Behind Custom is a picker we wrote — saturation/value square, hue slider,
and HEX / RGB / HSL inputs — not <input type="color">. The native input opens the OS picker,
which brings its own chrome and its own idea of a colour model into the middle
of your product. Dragging and arrow keys both work; the hex field accepts fff,
#FFF or #ffffff.
Saved colours
The star beside Custom saves the current colour; saved colours appear as a row under the palette. Six of them, most recent first — one row, because the popover has to keep fitting on a short window.
Persistence defaults to localStorage, namespaced by siteId so two Notant
instances on one origin don't collide. A host that knows who the user is can
keep them somewhere better:
favorites: {
get: () => api.getUserColors(), // may return a promise
set: (colors) => api.saveUserColors(colors),
}favorites: false removes the feature. The colour maths and list helpers
(addFavorite, removeFavorite, sanitizeFavorites) are exported too, so a
host implementing its own store gets the same capping and de-duplication.
The conversions are exported for reuse: normalizeHex, isLightColor,
contrastInk, hexToHsv/hsvToHex, hexToRgb/rgbToHex and
hexToHsl/hslToHex.
Transports
Notant makes no assumption about the backend. createHttpTransport posts multipart/form-data with each field as its own part, plus image as a file part.
interface IssueTransport {
submit(payload: IssueSubmission, image?: Blob | null): Promise<IssueSubmissionResult>;
}Field names title, details and category are load-bearing for ld-crm: its issue-form AUTO_DETECT map picks them up as subject, description and category. Renaming them fails silently — the request still returns 200, the fields just land nowhere.
What gets collected automatically
browser + version OS + platform language timezone
viewport w/h/DPR screen w/h/depth orientation colour scheme
network type downlink RTT saveData cookies enabled
device RAM CPU cores touch referrer
PWA standalonePlus the page URL. Refreshes on resize and orientation change.
Layout
src/
├─ core/ no Ionic; framework-agnostic contracts
│ ├─ clientEnv.ts diagnostics
│ ├─ transport.ts IssueTransport + createHttpTransport
│ └─ types.ts IssueSubmission
├─ react/
│ ├─ Notant.tsx mount point + workflow state
│ ├─ NotantFab.tsx launcher
│ ├─ config.tsx context: transport, getUser, getContext
│ ├─ screenshot/ selector, modal, annotation stage, controls
│ └─ experimental/ NOT exported — see below
└─ styles/The core/ boundary is kept deliberately, so a non-React build stays a possible additive change rather than a rewrite.
Not shipped yet
src/react/experimental/ holds QuickFeedbackModal (thumbs rating) and ContactModal (contact support), carried over from the original HWT branch. Both are unwired stubs — they console.log and simulate an 800 ms delay instead of submitting. They are excluded from the build and the public exports.
Screen recording was dropped entirely: it referenced a feedbackService module that never existed.
Known gaps
- No tests. The demo harness is the only verification path.
- The widget wears stock Ionic chrome; it doesn't adopt a host's design tokens.
~~Screenshots do not arrive~~ — fixed in ld-crm#495 and verified in production (#1).
Development
npm install
npm run typecheck
npm run build # → dist/notant.js, notant.cjs, notant.css, index.d.ts