@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:
FeedbackModaluses 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.
FeedbackActionscancel closes and resets the draft; in-flight uploads may finish, but their results are ignored because the draftsubmissionIdis discarded. - Success: submit success clears the draft immediately and leaves the success
status visible. The next open starts fresh.
SubmitStatus.successCloseDelayMsis 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 areprovider,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, andaction-send.- Svelte snippets —
FeedbackDialog,FeedbackLauncher,Recorder,AttachmentPicker,FeedbackActions, andSubmitStatusaccept 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.
getUserandgetTrustedContextresolve the authenticated user, the host-assertedhostContext(environment + optional session/launch ids), and thetargetthe 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, sotarget.id/target.keyare 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 errorResponse(stamping host telemetry on the way). Without it, helpers throw SvelteKiterror(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
limitsonly when the host needs stricter or looser caps.
