@creativeoak/rilo-feedback
v0.4.1
Published
Drop-in feedback annotation layer for preview deployments, wired to a Rilo project.
Downloads
1,032
Readme
@creativeoak/rilo-feedback
Let testers annotate a preview deployment. Everything they leave lands in a Rilo project — screenshot, annotation, the element they pointed at, and the browser they were in.
Built for Next.js App Router. The browser never holds a credential: a small route in your own app forwards to Rilo with the secret key.
Install
npm install @creativeoak/rilo-feedbackEnvironment
Set these on the preview/staging deployment only. Production omits them.
# Turns the whole thing on. Anything but "true" or "1" is off.
NEXT_PUBLIC_RILO_FEEDBACK_ENABLED=true
# Optional. Recorded with each item: local | preview | staging | production
NEXT_PUBLIC_RILO_FEEDBACK_ENV=preview
# Where Rilo lives.
NEXT_PUBLIC_RILO_API_URL=https://rilo.creativeoak.io
# The project's feedback key. NOT public — this must never be NEXT_PUBLIC_.
RILO_FEEDBACK_API_KEY=rf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxGenerate the key in Rilo: Project → Settings → Feedback integration. It is shown once. Regenerating revokes the previous one.
Next.js integration
Two files. First the route that holds the key:
// app/api/rilo-feedback/route.ts
import { createRiloFeedbackHandler } from "@creativeoak/rilo-feedback/server";
export const { GET, POST, PATCH, DELETE } = createRiloFeedbackHandler();Then the component, anywhere in a layout:
// app/layout.tsx
import { RiloFeedback } from "@creativeoak/rilo-feedback";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser();
return (
<html>
<body>
{children}
<RiloFeedback
user={user && { id: user.id, name: user.name, email: user.email }}
/>
</body>
</html>
);
}That is the whole integration. When NEXT_PUBLIC_RILO_FEEDBACK_ENABLED is not
true, <RiloFeedback> returns null before any hook runs: no button, no
listeners, no screenshot code, nothing.
Passing the current user
Feedback is for logged-in testers, and the SDK cannot know how your app
authenticates — so you pass the tester in. With no user, nothing renders.
<RiloFeedback user={{ id: session.user.id, name: session.user.name, email: session.user.email }} />For a preview deployment that has no login of its own:
<RiloFeedback requireUser={false} />With no user at all, the SDK gives the browser a generated one so that "see what I have left, and take that one back" still means something — see Who the feedback is from, which is the section to read before sending one preview URL to several people.
The identity is attribution, not authorization. Rilo records it and decides nothing by it; the API key is what proves which project may be written to. If your testers' identities matter, take them from the session on the server:
export const { GET, POST, PATCH, DELETE } = createRiloFeedbackHandler({
// Refuse anyone who is not signed in to the preview.
authorize: async (request) => Boolean(await getSession(request)),
// Last word on who gets recorded, taken from the server's own session
// rather than from the browser.
resolveTester: async (request) => {
const session = await getSession(request);
return session && { id: session.user.id, name: session.user.name, email: session.user.email };
},
});Attaching feedback to a task
<RiloFeedback user={user} taskId="0b2c…" />Or link testers from Rilo with ?riloTask=<task id> on the URL — the SDK picks
it up and it wins over the prop. Either way Rilo verifies server-side that the
task belongs to the project the API key resolves to; one that does not is
refused, not silently dropped.
How testers use it
| Gesture | What happens | | --- | --- | | Cmd-click (macOS) / Ctrl-click | Opens feedback on the element under the pointer, immediately | | Press and hold ~450ms | Nothing happens for the first ~180ms — an ordinary click has to leave no trace at all — then a ring appears and fills. Keep holding, move towards an option and release to pick it | | Floating button | The fallback, and it keeps saying what the gestures are. Drag it to any corner or the middle of any side — it snaps to the nearest and remembers. Hover it (or click it, on a touch device) for Mark area, Draw, Flows, Your feedback and How it works |
Four choices: Bug, Confusing, Idea, Mark area. The first three attach to the element under the pointer. Mark area opens a drawing layer with a rectangle and a freehand tool.
Then a small composer appears beside the selection. ⌘↵ sends, Esc cancels.
The first visit
The gestures are not discoverable — nothing on a page hints that holding the mouse down does anything — so the first time a browser loads a deployment with the SDK on it, four cards explain the tool and then never appear again. It is skippable from the first card, Escape closes it, and How it works on the floating button brings it back for anybody who skipped it.
What has been seen is kept as a version number under
rilo-feedback:walkthrough in localStorage, not a flag, so a later release
that adds a gesture can teach it without pestering someone who dismissed the
walkthrough a minute ago. A browser that refuses storage is never shown it —
an explanation that cannot be dismissed for good would open on every page load.
It does not run for somebody who arrived on an Open on preview link. They came to look at one specific thing, and teaching them the gestures over the top of it answers a question they did not ask.
Your feedback
Your feedback on the floating button lists everything this tester has left on this deployment, newest first, with the capture that came with each one.
Anything the team has not picked up yet can be reworded or withdrawn. An edit changes the comment and nothing else — the capture, the element and the browser context are measurements of a moment that has passed, and an edit that could move them would turn the evidence into something revisable after the fact. A withdrawal takes the row and its screenshot together.
Once an item is acknowledged or resolved, both stop being offered. It is half of a conversation somebody has already had: deleting it would take away the half that explains their reply, and quietly rewording it would be worse, because nothing about the change would show. The panel says Picked up on those rather than offering buttons that only exist to fail — and Rilo refuses both anyway, with a sentence saying why.
Deletion asks first, and is the one thing in the SDK that is not optimistic. Everything else puts itself on screen and reconciles behind you — an edit included, since a correction that failed to save can be put back word for word. A row that disappeared and came back because the request failed is a row the tester would reasonably assume was gone.
Who the feedback is from
This is worth being precise about, because a preview URL sent to five people is the normal case.
There are three answers, and the SDK uses the first one that exists:
resolveTesteron your route. The server's own session, and the last word — when it is set, nothing the browser says about who it is gets a hearing at all. This is the only answer worth anything if identities matter.- The
userprop. What the page rendered with. Fine for attribution, worth nothing as proof: it comes from the browser. - A generated id. With no user and
requireUser={false}, the SDK mints a random id, keeps it underrilo-feedback:testerinlocalStorage, and attributes feedback to Guest 4f2a — the first characters of that id, so a team reading a shared preview's inbox can at least tell two people apart.
The third exists so that "your feedback" means something on a deployment with no login. It identifies a browser, not a person: a second device, a private window, or cleared site data is a different tester, and there is no honest way around that without a login. The id is unguessable, which is what makes it safe enough to scope a deletion by — knowing somebody left feedback is not enough to remove it, you would have to hold their id.
Set resolveTester if any of that matters:
export const { GET, POST, PATCH, DELETE } = createRiloFeedbackHandler({
resolveTester: async (request) => {
const session = await getSession(request);
return session && { id: session.user.id, name: session.user.name };
},
});A deployment with no login of its own can still take the decision back from the
browser by minting the id in a cookie server-side and returning it from
resolveTester — same idea, but signed by you and not editable from a console.
Naming who may leave it
A project in Rilo can name the people it is testing with. Turn Only approved emails can leave feedback on under Feedback → Setup, add the addresses, and this deployment stops taking feedback from anybody else.
Nothing needs to change in your application. The SDK asks Rilo on mount whether this project is picky; when it is, a tester who reaches for feedback is asked for their address once, before the composer opens:
- Recognised — the composer opens, and it opens for every later piece of
feedback too. The address is kept under
rilo-feedback:approved-testerinlocalStorage, and re-checked on the next page load rather than trusted, so somebody removed from the list in Rilo stops being able to write there. - Not recognised — they are told so, on the panel, with nothing written and nothing lost.
- Your
userprop orresolveTesteranswers for them where it can. A deployment that already knows who is signed in never shows the panel — it offers that address instead, and the server's answer wins over a typed one.
Whoever the project names becomes the name on their feedback, so the inbox reads Jane Holm rather than Guest 4f2a.
This is a list, not a login. Nothing proves an address belongs to whoever
typed it, and the SDK does not send a code or a link to check. It keeps a
forwarded staging URL from turning into a client's inbox full of strangers,
which is what it is for; if you need to know who somebody is, put the
deployment behind your own authentication and set resolveTester.
Every write is checked again by Rilo against the same list, so the panel is a
courtesy and not the enforcement. A submission from an address that is not on it
is refused with 403 and code: "tester_not_approved" whatever the browser
believes.
Flows
A single piece of feedback says this is wrong. A flow says here is what I did, and here is where it went wrong — an ordered walk through the product that holds together across every page it crosses.
Flows on the floating button opens the sidebar, which asks what the tester is about to walk through. A flow needs a title — it is not defaulted, and not invented from the pathname. The name is asked for at the one moment the tester knows what they are about to do; afterwards a flow is a list of screenshots and a name they would have to reconstruct, which is how flows end up called "Untitled flow" three times over. The title can be edited while the flow is recording, but not emptied.
While a flow is recording, every gesture goes into it — Add step, a Cmd-click, a press-and-hold, a mark from the button. One journey is being described, and a bug found halfway through it is part of that journey; the label the tester picked (Bug, Confusing, Idea) is kept on the step. The button says Adding to your flow whenever the sidebar is closed, so it is always clear which one is happening.
Steps can be renamed, dragged by their number to reorder, nudged with ↑ / ↓, or removed while the flow is still recording. Discard marks the flow abandoned and it is never shown to the team; the sidebar stays open, ready for the next one.
Nothing waits on Finish. Every step is in Rilo the moment it is drawn, and every rename, reorder and edit saves on the spot, so a tester who closes the tab loses nothing — the team sees the flow in the project's Feedback tab badged In progress. Finish only moves it out of that state and hands it over.
Two things make this work across pages:
- Every step is uploaded as it is recorded. By the time the tester navigates away, the step is already in Rilo. Nothing is buffered in the browser, which is also why a reload or a closed tab costs nothing.
- The only thing kept locally is the flow's id, under
rilo-feedback:recording-flowinlocalStorage. On the next page the SDK reads it back, fetches the flow and reopens the sidebar where it was. A browser that refuses storage loses the cross-page part; the steps already recorded are safe either way.
Past flows lists the flows this tester has recorded on this project, and opens any of them read-only. Once a flow is finished it cannot be appended to — it is a record of what happened, and appending to it later would rewrite a journey somebody has already read.
The team reads flows in Rilo, under the project's Feedback tab, in the Flows view beside the individual items.
What the SDK will not interfere with
Press-and-hold already means something in plenty of places, so it never starts
on input, textarea, select, canvas, video, iframe, anything
contenteditable, anything draggable="true", or role="slider" /
role="application". It also cancels the moment the pointer moves more than a
few pixels, which covers dragging, scrolling and selecting text.
To opt an element (and its children) out yourself:
<div data-rilo-feedback-hold="off">…</div>A short click is never intercepted. Only a gesture that actually fired swallows the click it would have produced.
Stable element ids
The SDK works with no markup changes: it prefers an element's own id, then a
stable-looking attribute (data-testid, name, aria-label), and falls back
to a generated selector. It never builds a selector out of positions alone.
For anything you expect to keep referring to, give it a name:
<Button data-rilo-feedback-id="checkout-submit">Complete order</Button>That survives re-layouts, re-orderings and redesigns, and is what makes Open on preview land on the right element months later. Rilo shows in the viewer when an item had only a generated selector to go on.
Screenshots, and their limits
Each submission carries a picture of the viewport, rasterized in the browser by
cloning the DOM into an SVG foreignObject. No extension, no permission prompt,
no server round trip. The feedback UI is never in it — the whole overlay
lives in one element that the capture filter drops — and annotations are stored
separately, so Rilo can show the clean capture and the marked-up one.
Known limits of this technique, none of which stop a submission:
- Cross-origin images need permissive CORS headers to be rasterized. Without them they come out blank. Images from your own origin, and data URIs, are fine.
<canvas>is captured only if it is not tainted by cross-origin content. WebGL canvases usually come out blank unless created withpreserveDrawingBuffer.<video>renders as its poster frame or as nothing, not the current frame.<iframe>content is never captured — same-origin or not.- Cross-origin web fonts without CORS fall back to a system font in the capture, so text may reflow slightly.
- Images still loading outside the viewport are not waited for. Only the media actually in frame is, and only for about a second — a page whose lazy images below the fold have not loaded yet (or never will, being hidden or sizeless) is captured immediately rather than stalling on them.
- Shadow DOM is captured; closed shadow roots are not.
position: fixedelements land at the top of the capture, which is where the viewport is — this is usually right, and occasionally a pixel or two off.- Very large viewports are captured at a reduced pixel ratio to stay under the browser's canvas limit.
- The clone carries a named set of CSS properties, not every property the
browser knows about — the difference between a capture that takes five seconds
on a dense page and one that takes under a second. It covers box, type, paint,
layout and transforms, and on real screens the two come out pixel for pixel
identical; an exotic property outside the list would fall back to the
browser's default for that tag. The list is exported as
CAPTURED_STYLE_PROPERTIES, and pull requests adding to it are welcome. - Anything the capture cannot manage at all fails silently: the comment, the element and the page context are still submitted, and Rilo shows the item without a screenshot.
A marked area is cropped to the mark, and the mark is drawn on it. The capture is taken of the whole viewport and cut down once the tester stops drawing, then the shapes are painted into the image.
- The crop always contains the whole of the visible mark, with padding around it and a readable minimum, so a mark on one small button still comes with enough page to place it. It never comes out smaller than what was drawn.
- The mark travels with the picture: a thumbnail in the sidebar, a row in the inbox, a file dropped into a chat. A screenshot whose mark exists only in one viewer means nothing anywhere else.
- The shapes are still stored separately, in viewport coordinates, alongside the crop — that is what "open on preview" uses to scroll back to the right place. Rilo's viewer knows the mark is already in the image and does not draw it twice.
Feedback with nothing drawn on it keeps the whole viewport, untouched.
Turn captures off entirely with captureScreenshots={false}.
Open on preview
From the Rilo viewer, Open on preview opens the deployment at the page the
tester was on with ?riloFeedback=<id>. The SDK fetches that item through your
route, finds the element, scrolls to it, highlights it and shows the original
comment. If the element is gone, it says so quietly rather than throwing.
Props
| Prop | Default | |
| --- | --- | --- |
| user | — | The tester. Required unless requireUser={false} |
| requireUser | true | Whether a tester is needed at all |
| taskId | — | Attach everything on this page to one Rilo task |
| endpoint | /api/rilo-feedback | Your forwarding route |
| enabled | env | Overrides NEXT_PUBLIC_RILO_FEEDBACK_ENABLED |
| environment | env | Overrides NEXT_PUBLIC_RILO_FEEDBACK_ENV |
| previewUrl | location.origin | The deployment's canonical URL |
| holdDurationMs | 450 | Press-and-hold threshold. The ring appears part way through it, never at the start |
| holdToActivate | true | Turn the hold gesture off |
| showButton | true | Turn the floating button off |
| buttonPosition | bottom-right | Which corner |
| captureScreenshots | true | Turn screenshots off |
| zIndex | 2147483000 | Read once, at mount |
| onSubmitted | — | (feedbackId) => void |
| onError | — | (error) => void |
Behaviour you can rely on
- SSR safe. Renders nothing on the server and touches no browser API during render.
- Style isolated. The whole overlay lives in a shadow root, so your CSS cannot reshape it and its CSS cannot reach your page.
- Clean up. Every listener is removed on unmount, and the overlay element goes with it.
- Never makes the tester wait. The composer closes on the keystroke that submits it, and the crop, the upload and the round trip happen behind it. A flow step appears in the sidebar immediately and is replaced by Rilo's copy when it lands; every reorder and edit applies on the click. A failure puts back what was there and says so.
- Quiet when Rilo is down. A failed submission shows the tester one small message. Nothing throws into your app; nothing is logged in production.
- Small. One runtime dependency (
modern-screenshot) plus the shared contracts. React and React DOM are peers.
Development errors
In a development build the SDK explains itself in the console — a missing
user, a failed capture, an unreachable endpoint. In production it says
nothing at all.
