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

@playcademy/feedback

v0.2.3

Published

Feedback capture components, server route helpers, and API client for Playcademy apps

Readme

@playcademy/feedback

The shared feedback capture surface for host apps (Playcademy, Arcade). It is headless-first: the package owns the hard, reusable parts — a capture state machine, the record→transcribe→review and pick→upload flows, submissionId / usedStt bookkeeping, accessibility, and submit — and ships them unstyled and fully overridable. Hosts bring 100% of the look.

Hard rule: this package never holds OpenAI keys or feedback-service secrets. Speech-to-text, signed uploads, and canonical submit route through host-local endpoints; the SvelteKit route helpers that back those endpoints (where the host injects its secrets) live in the separate, server-only @playcademy/feedback/server export. See docs/dev/consumption.md.

Peer deps: svelte@^5, @sveltejs/kit@^2 (for the /server helpers).

Client: the capture UI

Wrap your app (or a region) in <FeedbackProvider> with your host-local endpoints, then compose the primitives — or build your own UI on the headless context.

<script lang="ts">
    import {
        FeedbackProvider,
        FeedbackLauncher,
        FeedbackDialog,
    } from '@playcademy/feedback/svelte'
</script>

<FeedbackProvider
    submitEndpoint="/api/feedback"
    transcribeEndpoint="/api/feedback/transcribe"
    attachmentEndpoint="/api/feedback/attachments"
    source="/play"
>
    <FeedbackLauncher />
    <FeedbackDialog />
</FeedbackProvider>

transcribeEndpoint / attachmentEndpoint are optional — omit them to disable the recorder / attachments.

Default package-owned limits apply to attachment count, combined attachment size, recorded-audio duration, and recorded-audio size. The bundled recorder stops automatically at the duration limit (5 minutes by default), transcribes what was captured, and shows a note. Hosts only pass limits when they need a product-specific override:

<FeedbackProvider
    submitEndpoint="/api/feedback"
    attachmentEndpoint="/api/feedback/attachments"
    limits={{ maxAttachments: 3 }}
>
    <!-- capture UI -->
</FeedbackProvider>

source is the origin of the feedback — the route/page it was raised from. It's browser-supplied and untrusted. What the feedback is about (a game, a lesson, the arcade itself) is a separate, host-asserted target, set on the server — see Server: route helpers.

Composed and primitive UI

<FeedbackDialog /> is the package-owned composition: it wires MessageField, Recorder, AttachmentPicker, AttachmentList, SubmitStatus, and FeedbackActions inside FeedbackModal. Use it when the host wants the package to own the ordinary feedback workflow end to end.

Hosts can still compose the same primitives when they need a different layout:

<FeedbackModal labels={{ title: 'Report a problem' }}>
    <MessageField label="What happened?" placeholder="Tell us what you saw…" />
    <Recorder
        labels={{
            start: 'Record',
            stop: 'Stop',
            durationLimitReached: 'Recording stopped at 5 minutes. Transcribing now.',
        }}
    />
    <AttachmentPicker accept="image/*" />
    <AttachmentList />
    <SubmitStatus successCloseDelayMs={1200} />
    <FeedbackActions labels={{ send: 'Send', cancel: 'Discard' }} />
</FeedbackModal>

The package owns behavior; hosts skin through classes, CSS variables, parts, and the small label/snippet surfaces where CSS cannot express the customization. FeedbackDialog forwards the customization supported by its child primitives: recorder button/status snippets, attachment button content, action button snippets, submit status snippets, and per-primitive classes.

<FeedbackDialog labels={{ actions: { send: 'Send' } }}>
    {#snippet startButton()}
        <MicIcon /> Record
    {/snippet}

    {#snippet stopButton()}
        <StopIcon /> Stop
    {/snippet}

    {#snippet attachmentButton()}
        <PaperclipIcon /> Attach
    {/snippet}

    {#snippet sendButton({ submitting })}
        <SendIcon /> {submitting ? 'Sending' : 'Send'}
    {/snippet}

    {#snippet cancelButton()}
        <XIcon /> Cancel
    {/snippet}
</FeedbackDialog>

Behavior contract

  • Accessibility: FeedbackModal uses native modal dialog focus management, Escape close, labelled controls, disabled form controls, focus-visible styling hooks, and live status/error announcements.
  • Close vs. cancel: closing the modal (close button, Escape, backdrop) preserves the draft message and attachments, but discards active recording and ignores any in-flight transcription result. FeedbackActions cancel closes and resets the draft; in-flight uploads may finish, but their results are ignored because the draft submissionId is discarded.
  • Success: submit success clears the draft immediately and leaves the success status visible. The next open starts fresh. SubmitStatus.successCloseDelayMs is opt-in and disabled by default.
  • Submit gating: send is disabled until there is message content and no recording, transcription, upload, or submit is in flight.

Going fully headless

Arcade-style hosts that want total control over markup can skip the primitives and drive the capture state machine directly:

<script lang="ts">
    import { getFeedbackContext } from '@playcademy/feedback'

    const capture = getFeedbackContext() // inside a <FeedbackProvider>
    // capture.message, capture.attachments, capture.canSubmit, …
    // capture.openModal(), capture.addAttachment(file), capture.submit(), …
</script>

(createFeedbackCapture(config) builds an instance without the provider, if you manage context yourself.)

Custom recorder UI

Voice recording is browser plumbing — microphone access, the MediaRecorder lifecycle, track cleanup, and the transcription handoff. createFeedbackRecorder owns all of it and exposes reactive state for a custom UI, so a host never touches navigator.mediaDevices or MediaRecorder:

<script lang="ts">
    import { createFeedbackRecorder, getFeedbackContext } from '@playcademy/feedback'

    const recorder = createFeedbackRecorder(getFeedbackContext())
    // recorder.supported, recorder.recording, recorder.transcribing,
    // recorder.starting, recorder.notice, recorder.error
</script>

{#if recorder.supported}
    {#if recorder.recording}
        <button onclick={() => recorder.stop()}>Stop</button>
    {:else}
        <button disabled={recorder.starting} onclick={() => recorder.start()}>Record</button>
    {/if}
    {#if recorder.transcribing}<span>Transcribing…</span>{/if}
    {#if recorder.error}<p>{recorder.error}</p>{/if}
{/if}

Create it during component initialization: it releases the microphone automatically when the capture surface closes and when the component unmounts (call recorder.cancel() to stop without transcribing, or recorder.destroy() to release it manually). The bundled <Recorder /> is just one consumer of this same controller. Recordings stop automatically at the configured recording duration limit and still go through transcription; check recorder.noticeKind / recorder.notice to inform users when that happens in custom UI.

Theming

The primitives carry only functional CSS. Style them however you like:

  • --fb-* CSS variables — import the optional baseline (@playcademy/feedback/styles.css) and override tokens for color, spacing, border, radius, type, focus, disabled, overlay, and modal width.
  • data-fb-part="…" attributes — part names are public semver surface. Stable parts are provider, launcher, modal, modal-backdrop, modal-surface, modal-header, modal-title, modal-close, modal-body, dialog, message-field, message-label, message, recorder, recorder-unsupported, recorder-start, recorder-stop, recorder-status, recorder-note, recorder-error, attachments, attachment-input, attachment-add, attachment-list, attachment-item, attachment-name, attachment-status, attachment-error, attachment-pick-error, attachment-remove, submit-status, submit-success, submit-error, submit-error-detail, actions, action-cancel, and action-send.
  • Svelte snippetsFeedbackDialog, FeedbackLauncher, Recorder, AttachmentPicker, FeedbackActions, and SubmitStatus accept high-value content snippets without handing the package's behavior back to the host.

No Tailwind or component-library dependency is imposed on consumers.

Server: route helpers (@playcademy/feedback/server)

The component only talks to host-local endpoints. Back them with the route helpers; this is where the host owns trust and secrets.

// src/routes/api/feedback/+server.ts
import { createFeedbackSubmitHandler } from '@playcademy/feedback/server'
import { createFeedbackClient } from '@playcademy/feedback/client'
import { env } from '$env/dynamic/private'

const feedbackClient = createFeedbackClient({
    baseUrl: env.FEEDBACK_SERVICE_URL,
    appId: 'arcade',
    clientSecret: env.FEEDBACK_CLIENT_SECRET,
})

export const POST = createFeedbackSubmitHandler({
    guard: async event => {
        await requireFeedbackAccess(event) // throw error(401/403, …) to deny
    },
    getUser: event => event.locals.user, // → UserContext | undefined
    getTrustedContext: event => ({
        hostContext: { environment: env.ENVIRONMENT },
        // What the feedback is about — resolved from the host's own records, never
        // the browser. Prefer `key` (a stable slug, constant across environments)
        // as your query handle; `id` is environment-scoped; `label` is display-only.
        target: { type: 'game', id: game.id, key: game.slug, label: game.name },
    }),
    feedbackClient,
})
// src/routes/api/feedback/transcribe/+server.ts
import { createFeedbackTranscribeHandler, openAITranscriber } from '@playcademy/feedback/server'
import { env } from '$env/dynamic/private'

export const POST = createFeedbackTranscribeHandler({
    guard: async event => {
        await requireFeedbackAccess(event)
    },
    transcriber: openAITranscriber({ apiKey: env.OPENAI_API_KEY }),
    rateLimit: async event => {
        await enforceRateLimit(event) // throw error(429, …) to deny
    },
})
// src/routes/api/feedback/attachments/+server.ts
import { createFeedbackAttachmentHandler } from '@playcademy/feedback/server'

export const POST = createFeedbackAttachmentHandler({
    guard: async event => {
        await requireFeedbackAccess(event)
    },
    // The picker's `accept` attribute only filters the file dialog — enforce
    // the product's attachment types here (exact types or `type/*` wildcards).
    allowedContentTypes: ['image/*'],
    feedbackClient,
})

Host responsibilities

  • Provide the host-local endpoints the component points at (submit / transcribe / attachments) using the route helpers above.
  • Own auth + trusted context. getUser and getTrustedContext resolve the authenticated user, the host-asserted hostContext (environment + optional session/launch ids), and the target the feedback is about (type + optional id/key/label). The submit helper overwrites any user/host-context/target the browser supplied — only the host asserts those, so target.id/target.key are safe to filter and authorize on.
  • Use the helper guards for route-local auth/rate-limit checks. All helpers accept guard(event), so submit, transcribe, and attachments can share the same host policy without hand-wrapping individual routes.
  • Map failures into your API envelope with onError(failure, event). All helpers accept it; every failure a route would answer — validation, limits, guard denials, service errors, unexpected throws — arrives normalized as { status, message, cause } and the hook returns the route's error Response (stamping host telemetry on the way). Without it, helpers throw SvelteKit error(status, message).
  • Hold the secrets. The OpenAI key and the feedback-service client secret live only on the host server.
  • Rely on package defaults for basic limits. Override limits only when the host needs stricter or looser caps.