* feat(app): proactive feedback card, time and usage gated
Show a one-time feedback card in the sidebar footer once a user is both
engaged and past the honeymoon window: two weeks since the first boot we can
observe, and at least 10 documents in the workspace. Showing it once
suppresses it forever on that device, whether the user answers or closes it.
The card sits beside OnboardingCardMount and UpdateNotices and mirrors
SubscribeCard: same footer slot, same bordered shell, form inlined rather
than behind a dialog. It holds back while either of those two is up, and
records nothing while held, so it defers rather than spending its single
impression.
New feedback-nudge-store mirrors subscribe-card-store: a module-level
singleton over localStorage with safe coercion, a pure eligibility predicate,
and idempotent recorders. Hydrated at boot in main.tsx alongside the other
nudge stores.
Visibility latches rather than deriving from eligibility alone. Stamping
shownAt falsifies the predicate, so a card reading the predicate directly
would appear and vanish on the next render; eligibility now gates only the
initial stamp, exactly as the onboarding card does.
The document count filters out anything under a .ok segment. /api/pages
serves the whole file index and skills-as-content puts .ok/skills markdown in
it, so every ok init project already reads as roughly ten pages from the
project SKILL.md plus its references. Starter-pack templates land under a
nested .ok too. Without that filter the 10-document gate would pass on day
one for everyone. Reuses hasOkPathSegment so the count and the file tree
agree on what is OK-managed.
FeedbackForm gains a compact variant (additive, default-off) so the dialog
renders exactly as before. In compact mode the message box, email opt-in, and
Send stay hidden until a rating is picked: rating is a required schema field,
so offering them earlier presents a control that cannot succeed. That drops
the collapsed card from 238px to 92px of footer.
The card heading is deliberately not the dialog title. A dialog the user
opened on purpose can be titled with the plain question; this card arrives
uninvited, sits beside two statement headings, and must not restate the
Good / Not great toggle directly beneath it.
Adds the first tests for FeedbackFormDialog, which had none, pinning that the
dialog keeps its own title and renders the whole form up front.
Card copy is a placeholder pending product sign-off.
* fix(app): cross-suppress the feedback card from dialog submits
Feedback given through Help > Provide feedback now stops the proactive
card, matching the subscribe card's existing contract that one subscribe
from any surface stops that nudge. Without it the card would ask again,
weeks later, of someone who had already given detailed feedback.
FeedbackFormDialog gains an optional onSuccess seam; HelpPopover wires it
to feedbackNudgeStore.dismiss().
Also from review:
- Test the writePersistedState guard. localStorage is ambient browser
authority, so the guard belongs; the test supplies a throwing storage
through the injected public parameter rather than mocking inside the
try, and asserts the promised outcome: the answer still holds in memory
for the session, so a full-storage device does not re-show the nudge.
- Test that switching back to Good drops the reason selection. The form
clears it so hidden pills cannot leak into the payload; the test
re-opens the pills to prove the selection is gone, not merely hidden.
- Skip the document count once the nudge has latched. The card lives in
the persistent sidebar footer and PageListProvider rebuilds pages as a
fresh Set each render, so the walk ran on every render for the app's
lifetime, for a card that can never appear again.
* fix(app): feedback card shown once, evaluated at launch, synced across windows
Three behaviour changes from a scenario review, plus the cross-window fix.
Shown once, ever. A prior session's shownAt now suppresses the card for good,
so ignoring it and relaunching does not bring it back — only submitting or
dismissing already did. shownBeforeRef (captured at mount) separates "already
had its session" from "showing it now", so recording this session's show does
not fold back into the guard. This restores the intended one-shot and makes
the store's own "suppresses forever" docs true again.
Evaluated at launch, not on the fly. Eligibility is decided once per session,
once the document set has loaded, and latched with evaluatedRef. Crossing the
tenth document via a live edit mid-session no longer surfaces the card — the
tenth document is the start of a work chunk, the worst moment to interrupt
(NN/g "task, then ask"). The card is simply present next launch instead.
Blocked at launch defers to the next launch. When another footer card owns
the space at launch, the feedback card stands down for the whole session
rather than popping in the moment that card clears. Consistent with only ever
surfacing at launch.
Cross-window sync. A storage-event listener on the singleton re-reads
persisted state when another window writes it, so a dismiss in one window
clears the card in every other open window without a reload. Follows the
enabled-agents / registered-agents pattern; the release-notes store is
IPC-fed and has no cross-window analog to borrow.
The count moved into the hook, computed only on the render that makes the
launch decision, so the O(pages) walk no longer runs on every sidebar render
— a cleaner resolution of the earlier perf finding than the interim guard.
* test(app): pin launch-latch below threshold; label rating group; card wording
Review follow-ups on the re-review.
Add a test for the eligibility latch landing below threshold. When the page
list loads under ten documents, evaluatedRef latches the session's one
decision before the eligibility check, so a later mid-session climb past ten
must not pop the card. The existing tests covered an already-loaded low count
and a load that settles above threshold, but not the ready-flip itself landing
low — the case that would regress if the latch moved inside the eligible
branch.
Give the Good / Not great rating group an accessible name. The options carry
their own labels but the group did not, so a screen reader announced the
radios without saying what they rate. The visible heading is a plain <p>, not
a programmatic label, so this is an aria-label rather than aria-labelledby.
Fix two field-level JSDoc comments that still said "toast" instead of "card".
* fix(app): feedback card session state survives sidebar remounts
Found in a pre-merge review of the show/hide logic. The card's session
identity (shown-this-session, the launch-decision latch, and the
prior-session guard) lived in per-mount React state. The shadcn Sidebar
tree remounts transparently across theme toggles and width changes — the
documented reason update-notices-store keeps its state at module level,
and why the onboarding and subscribe cards derive visibility purely from
their stores. On any such remount the fresh mount read the shownAt its
own predecessor had just persisted, classified it as a prior session's
show, and hid the card permanently — a theme toggle while the card was
up burned the one impression, likely unseen.
Session state now lives in a module-scoped FeedbackNudgeSession
(evaluated, shown), injectable for tests. A JS context is a session, so
the state survives remounts and resets exactly on reload or relaunch,
preserving the verified quit-and-relaunch behavior. The prior-session
guard fell away entirely: a fresh context has session.shown false and
the eligibility predicate already rejects on a persisted shownAt.
Regression test pins that a showing card survives an unmount/remount
with the same store and session; existing tests now inject per-test
sessions for isolation from the module default.
* docs(app): update feedback-nudge doc block for the session refactor
The remount fix replaced evaluatedRef and shownBeforeRef with the
module-scoped session, but the doc block's timing bullets still narrated
the deleted refs. Reworded to describe the session latches; no code
change.
GitOrigin-RevId: 508f378142024aa10e7b4bef9db74f0f175e5a5e