Cherry-pick of b49f2e0 from mai/iris/hotfix-render onto main. iris's
hotfix branched off 538419f (May 6) to avoid Phase 2, but prod was
actually already on c13d84d (Phase 2 deployed May 7-8), so deploying
her branch directly would have regressed Phase 2. Cherry-pick onto
current main resolves cleanly: package.json + +page.svelte
auto-merged; bun.lock regenerated via bun install.
Replaces the bare <p>{data.description}</p> on the participant page
with a marked + isomorphic-dompurify pipeline so admins can author
descriptions with categorized link lists. Scoped .fb-description
styles restore list bullets, give h1-h6 sensible scale below the page
title, and use the existing --color-primary / dark-mode tokens.
Both deps land in dependencies (not devDependencies) because the
render runs SSR-first.
Hotfix for the UPC Deadlines training (HL PA, 2026-05-28) — m wants a
curated Resources/Links block above the form.
Registry slot 7/7. The big one. Closes the server-side required-validation
gap the audit (§3.A) flagged: legacy submit/+server.ts only matched empty
strings and empty arrays, so a date_ranked_choice answer of `{}` (object
exists, but no options rated) passed the gate even when the question was
required. The client-side validator caught it; the server didn't.
THE source of truth for the gap is now `isAnswerEmpty(q, answer)`:
- undefined / null → empty
- empty object → empty (this is the case the legacy gate missed)
- object where every value is null → empty
- object with at least one finite integer 1..5 → not empty
- out-of-range / non-integer / wrong-shape → empty
After the wiring step (commit 11) flips submit/+server.ts to call
`getQuestion(q.type).isAnswerEmpty(q, answer)`, the gap closes by
construction — one rule, two callers, no drift.
Files added:
- date_ranked_choice.ts — schema (extends base with options[2..50] of
{id, start, end?, label?}, optional scale.{min,max}_label, optional
allow_partial), defaultStub (two slots starting at the next hour
+ 24h), isAnswerEmpty (the gap closer above), emptyStats with
per-option OptStatsWip accumulators, ingest (per-option counting,
range-checking, _sum), finalise (mean = _sum/count, sort by mean
desc with tiebreaks on 5-count / 4-count / count / id), CSV (one
column per option, header format <qid>[<optId>]), adminCellSummary
("X avg (N rated)").
- date_ranked_choice.input.svelte — per-option row with date display,
1..5 rating buttons, skip button. Same markup the participant page
renders today.
- date_ranked_choice.builder.svelte — date/time options list with add /
remove, scale labels (rating-1 / rating-5 captions), allow_partial
toggle. Includes the renamed `setDateRankedScaleLabel` from the
papercut commit. Date-handling helpers (isoToLocalInput,
localInputToIso, defaultStartIso, optUid) live here.
- date_ranked_choice.results.svelte — full calendar + bars view with
view-toggle (Kalender / Balken). All helper logic — buildCalendar,
cellTitle, colorForRating, colorForMean, fmtTimeRange, fmtDateOption,
fmtMean, mixHex — is now per-module instead of in Results.svelte.
- date_ranked_choice.test.ts — 22 cases covering schema (duplicate ids,
fewer than 2 options, malformed ISO, disallowed id chars), the seven
isAnswerEmpty rules above, ingest+finalise (sort, _sum drop, range
filtering, missing-answer handling), CSV (one column per option,
cell extraction, wrong-shape passthrough), adminCellSummary.
123 server tests pass (was 101). svelte-check + bun run build clean.
All seven types now in QUESTION_MODULES. The wiring step (next commit)
flips the legacy callers — schemas.ts assembles its discriminated union
from the registry, FormBuilder mounts BuilderEditor by type, participant
page mounts ParticipantInput, Results.svelte mounts ResultsBlock, the
submit endpoint calls isAnswerEmpty per type, the export endpoint calls
csvColumns + csvCellFor per type. After that, the legacy `q.type === '...'`
strips disappear.
Two choice types share the editor (option list with add/remove rows) and
the results bar chart. The participant input differs (radio vs checkbox)
and the answer shape differs (string vs string[]) — those are per-module.
Files added:
- single_choice.ts + multi_choice.ts — schemas (each with options array
≥2 items), defaultStubs (Option A / Option B), per-type isAnswerEmpty,
ingest with per-option counting + other_count for choices that don't
match (handles renamed options between form versions)
- single_choice.input.svelte — radio-button rows
- multi_choice.input.svelte — checkbox rows + toggle helper
- choice.builder.svelte — shared option editor (add / remove with the
≥2 minimum invariant); both modules import this slot
- choice.results.svelte — shared bar chart per option + an
"other / dropped" muted row when other_count > 0
- choice.test.ts — 11 cases covering schema (≥2 options invariant),
isAnswerEmpty (per-type rules), ingest (option counting + other_count),
CSV (single-string vs pipe-joined), adminCellSummary (per-type)
Both modules registered in QUESTION_MODULES (between long_text and
scale per the picker order).
101 server tests pass (was 91). svelte-check clean.
Two text-input types share the same shape — a short input vs. a multi-line
textarea is the only material difference at the participant level. Same
results rendering, same CSV expansion, same admin summary, same
sanitise-for-public strip-and-keep-count rule.
Files added:
- short_text.ts + long_text.ts — module data + schemas (each extends the
shared base with a `placeholder` field)
- short_text.input.svelte — single-line <input>
- long_text.input.svelte — multi-line <textarea> with rows=4
- short_text.builder.svelte — placeholder-text editor (shared between
both modules; long_text.ts re-imports it)
- text.results.svelte — shared answer-list / count-only results block
- text.test.ts — 11 cases covering schema accept/reject, isAnswerEmpty
(blank/whitespace/non-string vs. real text), ingest order + filter,
sanitizeForPublic strip-text-keep-count, CSV passthrough,
adminCellSummary
Both modules registered in QUESTION_MODULES.
types.ts fix: StatsForType<T> now uses intersection (`QuestionStats &
{ type: T }`) instead of `Extract<QuestionStats, { type: T }>`. The
existing TextStats variant declares `type: 'short_text' | 'long_text'`
as a single union, and `Extract<T, U>` narrows to `never` when T's
discriminator is a union and U narrows it; intersection works correctly.
80 server tests pass (was 70). svelte-check + bun run build clean.
First per-type module — establishes the file layout for the remaining six.
- lib/questions/boolean.ts — schema (re-uses FeedbackQuestionBaseSchema +
z.literal('boolean')), defaultStub, isAnswerEmpty, emptyStats, ingest,
finalise, sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary,
plus the three .svelte component slots.
- lib/questions/boolean.input.svelte — Yes/Nein radio pair, exactly the
same markup the participant page renders today (will be the receiver
when /f/[slug] flips to the registry dispatcher in commit 12).
- lib/questions/boolean.builder.svelte — empty placeholder; boolean has
no type-specific fields beyond the base. Slot exists so the registry
shape stays uniform across all seven types.
- lib/questions/boolean.results.svelte — count + percent bars, same as the
current Results.svelte branch.
- lib/questions/boolean.test.ts — 17 cases covering schema accept/reject,
isAnswerEmpty (true/false/undefined/null/non-bool), ingest+finalise
(yes/no counts, garbage ignored), CSV (single column, true/false/empty),
adminCellSummary (Yes/No/em-dash).
- lib/questions/_base.ts — FeedbackQuestionBaseSchema extracted for use
by the per-type modules. (Currently duplicates the private schema in
schemas.ts; commit 11 will flip schemas.ts to read from the registry
and drop the duplication.)
Registry: BooleanQuestion is the first entry in QUESTION_MODULES. The
legacy `q.type === 'boolean'` strips in FormBuilder / participant page /
Results.svelte / results.ts / submit / export still own dispatch — the
wiring step at the end of Phase 2 flips them.
70 server tests pass (was 58). svelte-check + bun run build clean.
Establishes the contract every per-question-type module must satisfy:
- lib/questions/types.ts — QuestionTypeModule<T> interface (schema,
defaultStub, isAnswerEmpty, emptyStats, ingest, finalise,
sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary,
ParticipantInput, BuilderEditor, ResultsBlock). Plus the helper aliases
QuestionForType<T> and StatsForType<T> (extract the discriminated-union
variant for type T) and the shared Svelte component prop shapes.
- lib/questions/registry.ts — QUESTION_MODULES (empty for now), getQuestion
(throws on unknown), hasQuestion, listQuestionTypes.
- lib/questions/registry.test.ts — locks the contract: getQuestion throws
with a helpful error pointing at lib/questions/<type>.ts when a module is
missing, hasQuestion returns false for nonsense, listQuestionTypes
matches the modules array.
Registry is intentionally empty in this commit — the legacy `q.type === '...'`
strips in FormBuilder, participant page, Results.svelte, results.ts, submit,
and export keep working. Per-type modules land in the next commits; the
final commit flips callers to use getQuestion(q.type) and the legacy strips
disappear.
Svelte component slots accept broadly-typed props and narrow internally on
question.type. The dispatch is sound at the call site (caller looked up by
matching type) but TypeScript can't prove the cross-component relationship
without significant generic gymnastics — runtime narrowing inside each
component is cheaper and keeps the registry literal simple.
58 server tests + 2 component tests pass. svelte-check clean.
Sets up the runtime split for §3.A's per-question-type modules: each type's
ParticipantInput / BuilderEditor / ResultsBlock will live in its own .svelte
file and want testable input handling. The pure logic (schema, isAnswerEmpty,
ingest, csvColumns, etc.) stays on `bun test`; the Svelte components run on
vitest.
Why two runners:
- Bun test doesn't apply the `browser` export condition when resolving ESM,
so it picks Svelte 5's `index-server.js` and @testing-library/svelte's
mount() throws lifecycle_function_unavailable.
- Vitest reuses the existing vite-plugin-svelte and applies the right
conditions natively. Run via `bun --bun vitest` so vitest itself executes
on bun (Node 18 is too old for vitest 4's node:util.styleText usage).
Files:
- New vitest.config.ts (jsdom env, svelte plugin, browser conditions, picks
up src/**/*.svelte.test.ts files only)
- New src/test-setup/vitest.ts — afterEach cleanup so consecutive render()
calls don't pollute each other's getByTestId lookups
- New src/lib/components/SmokeTest.svelte + .svelte.test.ts — sanity check
that the runner actually mounts a Svelte 5 component and reads props
- package.json scripts split: `test:server` (bun, 5 server files),
`test:components` (vitest), `test` runs both
- Pinned @sveltejs/vite-plugin-svelte to ^5.0.0 (v7 needs Node 22+ for
node:util.styleText; ours is on Node 18)
devDeps added (test-only): vitest, @testing-library/svelte,
@testing-library/jest-dom, jsdom.
54 server tests + 2 component tests pass. svelte-check + build clean.
§3.D of docs/plans/architecture-improvements.md.
The single-submission lookup query (session_id OR IP+UA, ordered by
created_at desc, limit 1) was inlined verbatim in three places: the
participant page server load, the public GET endpoint, and the submit gate.
Extracting it concentrates the priority rule (session-first, IP+UA fallback)
in one helper.
Splits feedback.ts in two so the pure parts are unit-testable. Existing
rate-limit.test.ts already noted that bun:test can't resolve SvelteKit's
$env/dynamic/private through the supabase.ts → fdb.ts chain, so anything
DB-aware can't be tested directly. The extraction follows the same pattern
used for admin-route in the previous commit.
Files:
- New lib/server/feedback-pure.ts — generateSlug, RATE_LIMIT, clampUserAgent,
parseFormDefinition, lookupPlan (the strategy planner for findExistingSubmission),
FeedbackInstance / LookupKeys / LookupStrategy types. No env imports.
- lib/server/feedback.ts — re-exports the pure helpers (existing callers
unaffected) and now hosts findExistingSubmission + getInstanceBy{Slug,Id}.
- New lib/server/feedback-pure.test.ts — 22 cases covering generateSlug
(length / alphabet / 5000-element collision smoke), clampUserAgent
(null / passthrough / truncate-at-500), parseFormDefinition (encoded
string / already-decoded / null / preserves other fields — locks the
supabase-js JSONB-as-encoded-string contract), lookupPlan (8 rows
covering empty / session-only / ip+ua-only / both / partial ip-only /
partial ua-only / empty-string sessionId / overlong sessionId).
Call sites rewired:
- routes/f/[slug]/+page.server.ts — IP+UA only (sessionId lives in
LocalStorage, not in the request — server can't see it on first paint)
- routes/api/public/feedback/[slug]/+server.ts GET — session_id (from
query string) + IP+UA fallback
- routes/api/public/feedback/[slug]/submit/+server.ts POST — same, the
single-submission gate
Behaviour unchanged. 47 tests pass. svelte-check + bun run build clean.
§3.C of docs/plans/architecture-improvements.md.
Lifts the auth + ownership + try/catch preamble that was inlined across
four admin endpoints into a single wrapper. Each endpoint now:
export const POST = withOwnedInstance(async ({ inst, event }) => {
// inst is guaranteed valid + owned, errors caught + tagged
}, 'admin feedback X');
Files:
- New lib/server/admin-route.ts — runtime wiring (requireAuth, getInstanceById,
handleApiError, Response helpers).
- New lib/server/admin-route-decision.ts — pure ownership decision branch.
Lives in its own module so bun:test can exercise it without pulling in
$env/dynamic/private through the feedback.ts → supabase.ts chain (same
constraint as the existing rate-limit.test.ts comment).
- New lib/server/admin-route.test.ts — 4-row decision-table test
(anonymous → 401, missing instance → 404, foreign owner → 401, owner → ok).
Endpoints rewired (auth+ownership boilerplate removed):
- /api/admin/feedback/[id]/+server.ts (GET / PATCH / DELETE — local `ownerOf`
helper deleted, was only used here)
- /api/admin/feedback/[id]/posts/[post_id]/hide/+server.ts
- /api/admin/feedback/[id]/share/+server.ts
- /api/admin/feedback/[id]/export/+server.ts
The list endpoint /api/admin/feedback/+server.ts has the auth half but no
ownership half (it lists by owner_user_id = userId), so it stays unchanged.
Behaviour unchanged. 29 tests pass. svelte-check + bun run build clean.
- `aggregateResults` ingests rating maps, tallies per-option counts +
histogram (1-5 buckets) + running sum, then `finalise` computes per-option
means and sorts options by mean desc with tiebreaks (count of 5s, then
4s, then total count, then id). Question-level `count` reflects
submissions that rated at least one option.
- Out-of-range, fractional, and non-integer ratings are silently dropped —
the aggregator never trusts user data, schema validates it on submit.
- CSV export expands a date_ranked_choice question into one column per
option named `<qid>[<optid>]`. JSON export is unchanged (it serialises
the rating map directly).
- New `results.test.ts` covers: per-option counts and means, histogram
tallying, mean-with-tiebreak ordering, ignoring bad ratings, and missing
answers. Wires the file into the `bun test` script.
Refs m/fdbck#1.
Bootstrap from /home/m/dev/web/msbls.de template:
- SvelteKit 2.15 + Svelte 5 + adapter-node + bun + vite 6
- Deps trimmed: @supabase/supabase-js, postgres, zod (+ dev: kit, vite-plugin-svelte, svelte-check, typescript)
- No mbrian-core submodule (irrelevant for fdbck)
- src/app.html minimal (no fonts, no theme toggler)
- src/app.d.ts declares App.Locals { userId: string | null }
- robots.txt Disallow: / (whole app is naked, per-link or auth-only)
- .env.example: Supabase + PUBLIC_SITE_URL + optional COOKIE_DOMAIN
Initial mai init scaffolding (.claude, .m, .mcp.json, AGENTS.md) bundled in
this first commit since the repo was empty before bootstrap.
Spawned from m/flexsiebels.de#63 pivot — see docs/plans/feedback-feature.md
for the full spec (copied in next commit).