All documents

Feedback and triage

09-states-and-feedback.md is the system telling the READER what happened. This is the reader telling the system — and it is a different problem with a different answer.

Shift+Click anything and report it. The person types two things; the app captures everything else and files it somewhere an agent can work. That loop is the block: the capture, the form, the queue, and the skill that drains it.

Installed with pnpm dlx shadcn@latest add @8020iq/feedback.

Why it exists at all

A bug report from inside a product normally arrives as a sentence in a chat thread — "the coverage number looks wrong on the map" — and costs three follow-up questions before anyone can act: which screen, which scope, what was selected, what did the console say, how did you get there. Every one of those answers is in the DOM at the moment of the complaint and gone a minute later.

So the form asks for the two things a machine cannot know, and takes the rest:

The reporter typesThe app captures
Bug, or recommendation
The element, its section, its selector, its label
One sentence
The route, the query, the tab, the title
Every number visible near the click
Whatever each mounted tool registered about its own state
The last console errors, the route trail, the browser, the theme

If a field can be filled by looking at the page, it does not belong on the form. Asking a person to retype what the machine can read is how a report becomes a chore and stops being filed. The form is two controls, and adding a third is the change that kills it.

The gesture

A switch in the account menu arms feedback mode. While it is armed:

  • Shift+Click captures what you clicked and opens the form. A plain click still navigates.
  • The cursor becomes a crosshair only while Shift is held — with the modifier up you are still using the app, and a permanent crosshair would say otherwise.
  • A pill at the bottom names the gesture. It has to: a modifier key is invisible, with no hover state and no menu that reveals it, so the one element present while the instruction applies is where the instruction lives.
  • Escape disarms — but only when Escape is not already spoken for. If any overlay is open ([role="menu"], dialog, listbox, tooltip) the key belongs to it. Skipping that check means arming the mode from the account menu and disarming it with the same Escape that closes the menu, which reproduces every time and reads as the switch not working.

Why Shift and not a plain click. Capturing every click freezes the app: you cannot reach the screen you want to report. The modifier keeps the surface fully usable while armed, which is what makes a multi-screen report possible at all.

The mode survives a route change — the screen worth reporting is usually three clicks from where you decided to report it — and does not survive a reload. A global click interceptor that comes back from the dead on Monday is a support ticket about the app eating clicks.

What you mount

<FeedbackStoreProvider store={yourStore}>
  <FeedbackProvider>
    {children}
    <FeedbackOverlay />
  </FeedbackProvider>
</FeedbackStoreProvider>

Once, above every route, inside your session provider. Then in your globals:

@import "@8020rei-com/tokens";
@import "./components/feedback/feedback.css";

feedback.css is the crosshair and nothing else. It has to be !important from the root: the rail, the header, the table rows and a map canvas each set their own cursor, so a less specific rule reaches none of them — and the one element whose cursor is wrong is the one the reporter is pointing at.

ExportFileWhat it is
FeedbackProvider
components/feedback/feedback-provider.tsx
The mode, the Shift+Click capture, the submit
useFeedback
same
The mode, for the switch that toggles it
useFeedbackOptional
same
The same, without throwing outside the provider — a sign-in screen renders outside it
FeedbackOverlay
components/feedback/feedback-overlay.tsx
The aim ring, the pill, the form
FeedbackInbox
components/feedback/feedback-inbox.tsx
The queue screen. Mount it at /feedback inside your shell

The store is the only seam

Everything else is portable — the capture is DOM, the overlay is React, the queue is a list. Where a report GOES is not. So the block takes a FeedbackStore and imports no database client of its own; this is the same shape lib/session.tsx uses for auth.

interface FeedbackStore {
  create(input: CreateFeedbackInput): Promise<string>
  load(): Promise<FeedbackItem[]>
  move(id: string, to: FeedbackStatus, actor: FeedbackActor): Promise<void>
  setPriority(id: string, priority: FeedbackPriority): Promise<void>
  respond(id: string, response: string, source: "human" | "agent"): Promise<void>
}

Five methods and no more. What your implementation owes the block:

  • create sets status: "pending" and priority: "medium" itself. They are not parameters. A report arrives unjudged and the two fields that judge it belong to whoever triages, so a hand-written row cannot walk in already marked done.
  • move refuses an illegal transition by throwing, and reads-then-writes atomically. Two people triaging the same queue on a Monday morning is exactly the race that loses one of two transitions.
  • Every write throws an Error whose message is a sentence, because the screens print it verbatim. PERMISSION_DENIED is not a sentence.
  • Enforce all of it in your backend's rules as well. This is the browser's copy of the contract, and the browser is not a gate.

With no provider the value is UNWIRED_STORE: reads answer empty, writes throw a sentence saying why. An unwired checkout is a supported state, exactly as an unconfigured auth layer is — a form that appears to work and drops every report on the floor is the alternative. memoryStore(seed) keeps everything in an array; it is the worked example of the contract and how the design site demonstrates the whole loop with no backend. It is not a production fallback: a reload empties it, which is the honest behaviour for something that was never persisted.

useFeedbackStore() reads it. FeedbackStoreProvider supplies it.

The lifecycle

lib/feedback/types.ts holds it as data, not as if statements spread over a screen: FeedbackStatus, STATUS_LABEL, STATUS_ORDER, VALID_TRANSITIONS and isValidTransition.

pending ──▶ in-progress ──▶ done
   │             │
   └──▶ dismissed ◀┘ ──▶ done

done is terminal; dismissed is not. A report that was waved away and then turned out to be real has to be able to land somewhere, and reopening it as done is the honest record: it was dismissed, then it was fixed. The reverse — undoing a fix — is a new report, because the fix happened.

There is no delete, and there must not be one. A filed report is a fact. dismissed is how you say "we looked and we are not doing this", and it keeps both the report and the decision — deleting loses both, and lets an embarrassing defect be quietly unfiled by the person it embarrasses.

FeedbackKind is bug | idea (KIND_LABEL renders idea as Recommendation) and there is no third value. "Question" was considered and cut: a question is answered in chat in a minute, and a queue that collects them becomes a help desk nobody staffs. FeedbackPriority is set by whoever triages, never by whoever reports — asking reporters to rate their own reports produces a queue that is entirely high, which is a queue with no priority in it.

The form defaults to Recommendation. Most of what gets reported on an internal surface is "it should be different", so the default is the common case and the person filing an actual bug has a reason to reach for the other control. The cost is real and worth watching: if bugs start arriving labelled as recommendations, that default is what did it.

The capture

lib/feedback/capture.ts is pure functions over the DOM — no React, no client, no import from any app. The rule it obeys is: never throw. It runs inside a click handler on a page the reporter is already unhappy with, and a capture that crashes turns a bug report into a second bug. Every extractor has a floor value, and a capture that fails entirely still opens the form with the page context.

captureTarget walks up from what the pointer hit to the thing a person would NAME — nobody means "the span holding the digits", they mean the tile — using findMeaningfulElement, inferElementType, extractLabel, findSectionName, buildSelectorPath, extractVisibleText and findParentHint. extractVisibleValues scans the surrounding panel for things that look like measurements and pairs each with the label beside it, capped at twelve. capturePage, toolOf, captureDevice and detectPageState take the rest.

data-feedback-label is worth more than any heuristic here. A component that declares one short-circuits the whole walk: kpi-recent-share is unambiguous, and "the div with 67.3% in it" is not. The report says which of the two it got, and an inferred label must be verified against the source before it is trusted to name a component. Devices that already receive their copy can derive a label at no cost to their call sites — that is usually the cheapest fix available when reports keep arriving unlabelled.

lib/feedback/recorder.ts holds the three things that are true about a session and gone when it ends, as module state rather than React state — writing them must never cause a render:

  • watchErrors wraps console.error rather than replacing it (the original is called first, every time — a capture layer that swallows what it captures is the worst version of this feature) and also listens for error and unhandledrejection, because the errors worth having are the ones nobody logged. recentErrors reads them back.
  • recordVisit / navigationPath keep the route trail. That trail is what turns "this screen is empty" into a reproduction: it is empty when you arrive from the map and not when you load it directly.
  • registerContextSource(name, read) / readAppState let a tool volunteer what it believes — the scope in force, the cut, the build the data came from — without this layer importing anything from that tool. Keys are namespaced by the source's name, and a source that throws is skipped rather than allowed to take the report down with it.

Nothing here is persisted. A report carries what was true when it was filed; a reader's browsing history is not an app's to keep past that.

The queue

FeedbackInbox is the board. Everyone in the workspace reads the whole list — a report about a shared internal tool is not private, and a queue where you can only see your own collects the same defect four times. What an admin gets is not a different list; it is the three controls that triage one.

It is cards, not a table, and this is the one place that departs from 07-tables-and-data.md. A row works when the fields are short and comparable; a report is a paragraph of prose, four blocks of captured context and possibly a console stack. In a table that is one column of ellipsis and a drawer to read anything.

The status selector at the top is a CountFilter — a row of counts rather than a tab strip, because the count is the information (04-buttons-and-controls.md). The captured context sits under a <details>, folded away: nobody needs to read it, but a form that ships your console log and the URL of the screen you were on should say so where the person filing it can see it before they press send.

The Copy button is the point of the whole feature. reportAsMarkdown (lib/feedback/report-md.ts) puts the report and every captured block on the clipboard in the format the thing that FIXES it reads. Every block is emitted including the empty ones — "Console errors: (none)" says the capture ran and found nothing, which a missing section does not.

The agent that drains it

The block ships .claude/skills/feedback-triage/SKILL.md. Fill in the two marked places with your repo's read path and its store, and an agent can work the queue end to end: take the pending list, claim a report, read the capture before the code, fix or refuse, then respond and move it.

Four rules in that skill are not negotiable, and they are the same four the UI enforces: never delete a report; never respond as a human (respond stamps responseSource: "agent" and the screen labels it); never trust visibleValues as a measurement — they are evidence of what the reporter SAW, not of what the data says; one report, one change.

Close the loop in the queue, not just in git. A fixed report that still reads pending means the next run does the work twice.

The shapes

lib/feedback/types.ts, and nothing imports anything: FeedbackTarget, FeedbackPage, FeedbackAppState, FeedbackDevice, FeedbackEnvironment, FeedbackStatusChange, FeedbackActor. FeedbackCapture is everything a report needs except the two things the reporter types; FeedbackItem is a capture plus the report, its triage state and its history.

FeedbackStatusChange.at is an ISO instant from the CLIENT clock, deliberately: several document stores refuse a server-timestamp sentinel inside an array element, and the document's own updatedAt — written in the same operation — stays the authority for ordering.