npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-feedback

Environment

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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Generate 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:

  1. resolveTester on 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.
  2. The user prop. What the page rendered with. Fine for attribution, worth nothing as proof: it comes from the browser.
  3. A generated id. With no user and requireUser={false}, the SDK mints a random id, keeps it under rilo-feedback:tester in localStorage, 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-tester in localStorage, 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 user prop or resolveTester answers 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-flow in localStorage. 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 with preserveDrawingBuffer.
  • <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: fixed elements 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.