Adds a non-answerable 'resources' question type so admins can place
a markdown content block anywhere in the questions array. Renders
inline on /f/<slug> as headings, lists, and links; no answer is
collected, no CSV column, no stats. Admin Results tab filters
resources out so it never shows an empty results card.
Shape per Phase 2 registry: lib/questions/resources.ts + .input.svelte
(participant render via shared lib/markdown.ts helper) + .builder.svelte
(textarea for authoring) + .results.svelte (placeholder so the registry
slot is consistent). Schema discriminated union picks up the new type
via lib/schemas.ts + registry.ts.
Description on /f/<slug> now routes through the same renderMarkdown()
helper instead of its inlined copy.
Motivation: HL PA UPC Deadlines training 2026-05-28 — m wanted the
resources block to be its own positionable thing inside the form,
not crammed into the description above the title.
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.
Per-screen + per-server-helper audit. Identifies six deepening
opportunities ranked by leverage:
T1: §3.A per-question-type module bundle (highest leverage; closes a
real server-side date_ranked_choice validation gap; unblocks i18n)
T2: §3.C withOwnedInstance wrapper, §3.D findExistingSubmission helper,
§3.F testability gaps
T3: §3.B ChatPanel + FormEditor component extraction, §3.E
feedback_instances repository
Each candidate explained against the deletion test (LANGUAGE.md vocab):
modules, depth, locality, leverage, seams, adapters. Tier-1 is the only
load-bearing refactor; tier-2 are small independent wins; tier-3 is
medium-cost cleanup that gets easier after tier-1.
Anti-scope listed: no new deps, no CSS split, no real-time migration,
no auth tier on /f/[slug], no port/adapter for fdb (one adapter =
hypothetical seam, per LANGUAGE.md).
5 open questions for m at the end before any code lands.
Design only — no source files touched. Awaiting m's pick before any
coder shift.
After commit 10's ParticipantInput dispatch, the per-type helpers in the
participant page that the legacy inline rendering relied on are dead:
- toggleMultiChoice (only used by the old multi_choice render block)
- setDateRankedRating (only used by the old date_ranked_choice render block)
- dateRankedRating (only used by the old date_ranked_choice render block)
Removed. setAnswer is the one remaining helper — it's the callback wired
to ParticipantInput's setAnswer prop.
The three remaining `q.type === '...'` references in this file are
intentional locale customizations (boolean Ja/Nein label override and
date_ranked_choice per-option breakdown with formatted dates in the
previousSubmission summary view) — they don't fit the registry contract
and are flagged for m/fdbck#3 (i18n) when that ticket lands.
Verified post-Phase 2:
- 0 inline `q.type === '...'` strips outside lib/questions/ except the
locale customizations above
- 0 errors, 25 warnings (pre-existing — same set as before Phase 2 plus
one a11y warning in date_ranked_choice.builder.svelte that mirrors the
legacy FormBuilder warning that's now gone)
- 123 server tests + 2 component tests pass
- bun run check + bun run build clean
Flips the four client-side per-type strips to registry dispatch.
FormBuilder.svelte (438L → 122L):
- Replace seven per-type editor branches with a single
`<Editor question={q} update={...} />` mount where Editor =
getQuestion(q.type).BuilderEditor.
- Replace the static TYPES array + TYPE_LABELS map with iteration over
QUESTION_MODULES (preserves picker order; module label is the source
of truth for the picker text).
- Replace the local defaultQuestion(type) factory with
getQuestion(type).defaultStub() — the changeType + add helpers route
through the registry too.
- Remove the inline date helpers (isoToLocalInput, localInputToIso,
defaultStartIso, optUid) — they now live in
date_ranked_choice.builder.svelte alongside the only callers.
routes/f/[slug]/+page.svelte (participant page):
- Replace seven per-type input branches with a single ParticipantInput
mount: `<Input question={q} answer={...} setAnswer={...} />`.
- submitForm validation calls `getQuestion(q.type).isAnswerEmpty(q,
answer)` for the required gate. The date_ranked_choice
`allow_partial: false` "rate every option" rule stays as a
client-only UX nudge — it's not a security gate, the server treats
partial answers as valid.
- summariseSubmittedAnswer (used by the previousSubmission summary view)
delegates to the registry's adminCellSummary for everything except
boolean (German "Ja/Nein" vs registry's English "Yes/No") and
date_ranked_choice (the participant summary uses a per-option
breakdown with formatted dates that the registry's terse "X avg
(N rated)" string doesn't carry).
Results.svelte (375L → 25L):
- Replace four per-type results branches with a single ResultsBlock
mount: `<Block question={q} stats={q.stats} />`.
- Per-type ResultsBlocks updated to match the existing German labels
(Schnitt / Antworten / Andere [frühere Versionen]) so the visible
output is byte-compatible with what's deployed today.
- All helpers — buildCalendar, cellTitle, colorForRating, colorForMean,
fmtTimeRange, fmtDateOption, fmtMean, mixHex, weekdayFmt, etc. — are
now per-type (most live in date_ranked_choice.results.svelte where
they're actually used).
routes/admin/feedback/[id]/+page.svelte:
- Replace local summarizeAnswer (type-agnostic, used the wrong rendering
for date_ranked_choice — averaged across all values instead of just
numeric ratings) with `getQuestion(q.type).adminCellSummary(q, answer)`.
- answerCellFor now takes the question (for type) instead of just qid.
- Per-type rendering for the submissions table is now correct for every
type — previously the JS-typeof dispatch produced garbage for some
shapes.
123 server tests pass. svelte-check + bun run build clean. No new
warnings — Phase 2 actually dropped 14 a11y warnings (FormBuilder's
unattached labels are gone with the per-type editor extraction).
After this commit, there are zero `q.type === '...'` strips in the
codebase outside the per-type modules themselves. Adding a new question
type is one file plus one line in registry.ts.
Flips three callers from inline per-type strips to registry dispatch.
THE main payoff: the date_ranked_choice required-validation gap that the
audit doc flagged is now closed by construction.
submit/+server.ts:
- Replace the inline empty-check loop with
`getQuestion(q.type).isAnswerEmpty(q, body.answers[q.id])`.
- The legacy rule matched only string/array empties; date_ranked_choice's
`{}` answer (object exists but no options rated) passed even when the
question was required. ONE source of truth, two callers (client +
server), no drift possible.
results.ts:
- aggregateResults walks the form definition and routes each question
through `getQuestion(q.type).{emptyStats, ingest, finalise}`.
- All seven per-type emptyStats / ingest / finalise functions deleted
(~150 lines).
- publicResults delegates to each module's sanitizeForPublic. The
hardcoded text-strip rule moves into short_text/long_text modules.
- ScaleStatsWip / DateRankedOptionStatsWip private types deleted —
each module now owns its own accumulator shape.
export/+server.ts:
- Replace the per-type CSV column expansion (the date_ranked_choice
one-column-per-option special case + the otherwise-one-column-per-question
fallthrough) with a uniform `getQuestion(q.type).csvColumns(q)` walk.
- Replace the per-cell answer extraction with
`getQuestion(q.type).csvCellFor(q, answer, col)`.
schemas.ts:
- Per-question-type schemas deleted (~40 lines). FeedbackQuestionSchema
imports each module's schema directly and assembles the discriminated
union. Adding a new type means: create lib/questions/<type>.ts +
register in registry.ts + add one import + one tuple entry here.
(Kept the explicit tuple because TypeScript can't infer a properly-
narrowed discriminated union from a runtime-built array — the inferred
FeedbackQuestion type would lose specificity at every call site.)
123 server tests pass — all existing assertions still hold against the
registry-routed aggregator. svelte-check + bun run build clean.
Client-side wiring (FormBuilder, participant page, Results.svelte, admin
detail submissions table) lands in the next commit.
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.
Two small cleanups that calm the diff for the upcoming §3.A per-question-
type module migration:
1. Rename setScaleLabel → setDateRankedScaleLabel in FormBuilder.svelte.
The function only handles date_ranked_choice's nested scale.{min,max}_label
(early-returns on every other type); the standalone scale question type's
labels use inline handlers. The original name was a half-grown helper from
the m/fdbck#1 add. The rename removes a real "wait, why does this skip
scale questions?" papercut that the audit doc flagged.
2. results.ts: replace the (s as ScaleStats & { _sum?: number })._sum
inline-cast-and-coalesce pattern with proper ScaleStatsWip /
DateRankedOptionStatsWip accumulator types. _sum is now a real, typed
field during ingest; finalise reads it and drops it before returning the
public stats shape. No behaviour change, no new tests needed — existing
results.test.ts still passes.
Also: kept `void q;` but expanded the comment to explain that q is the
schema definition (kept in signature for the upcoming per-type module
refactor that will dispatch via q.type).
54 tests pass, svelte-check clean, build clean.
§3.F (subset) of docs/plans/architecture-improvements.md.
Honeypot:
- Extract the `body.company && body.company.length > 0` check that was
inlined in /submit and /posts into isHoneypotTrap(body) in feedback-pure.
Same rule, two callers — locks the trap behaviour in one place. 5
cases: missing / empty-string / null / non-empty / single-space all
classified as expected.
publicResults:
- Extend results.test.ts: 3 cases proving short_text + long_text answers
are stripped from publicResults output while counts are preserved and
scale/numeric questions pass through untouched. The participant page's
"live results after submit" path leans on this — without the strip,
free-text answers (which can carry PII or contributor identity) would
leak to anonymous participants.
- Also asserts publicResults does not mutate the input (JSON-stringify
round-trip).
54 tests pass across 5 files. svelte-check + bun run 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.
- rows='1' → rows='3' so the compose box has presence even when empty
(it was a one-line slit, easy to miss).
- Add `field-sizing: content` on .fb-compose__textarea so modern browsers
(Chrome 123+ / Firefox 137+ / Safari 17.4+) auto-grow it to fit content
natively. Cap at the existing max-height: 10rem with overflow-y: auto so
long messages don't eat the bubbles scroll area.
- For older browsers: feature-detect via CSS.supports('field-sizing',
'content'). When unsupported, an oninput handler sets style.height =
scrollHeight + 'px' (capped at 160px). Modern browsers no-op the JS
resizer to avoid fighting the native CSS rule.
- After send (chatBody → ''), an $effect clears the inline height so the
textarea snaps back to rows='3'. Native field-sizing handles this on
its own; the effect only fires on the JS-fallback path.
- Shorten placeholder to "Nachricht…" — the keyboard hint was chrome the
user discovers on their own.
m: "The size of the chat should be limited to the screen size (- frame)
so it does not go via the whole long form..."
The grid layout (1fr 1fr at ≥900px) was stretching the chat column to
match the form column's height. With a 20-question form the right side
of the page became a 3000px scroll-soup of empty chat space.
Two-line fix:
- .fb-participant gets `align-items: start` at ≥900px — grid items take
their own natural height instead of stretching to the tallest sibling.
- .fb-participant__col--chat gets `max-height: calc(100vh - 2rem)` always
+ `position: sticky; top: 1rem` at ≥900px. Long form scrolls past it;
chat stays pinned in the viewport. On mobile (single-column stack) the
existing 70vh cap stays — the desktop max-height applies on top of it,
so mobile uses min(70vh, 100vh - 2rem) which is just 70vh.
No markup changes — pure CSS.
Four bugs from m's smoke pass on the just-shipped single-submission feature:
1. Reload showed the legacy "you can submit again" branch instead of the
read-only summary, because the client never refetched its previous
submission. Fix: page server load now does an IP+UA backstop lookup so
first paint is correct; client onMount supplements with a session_id
lookup against the new GET ?session_id= variant for the
cleared-cookies-but-same-browser case. Renamed JSON field
previous_submission to keep server/client shape symmetric. Same parametised
.eq() pattern as the submit handler — no PostgREST .or() with a
user-controlled session id.
2. Trailing colon in "Du hast schon abgesendet. Du kannst trotzdem nochmal
antworten:" reads like an unfinished sentence. Rewrote as a question:
"Du hast bereits abgesendet. Möchtest du eine weitere Antwort senden?"
The branch is now also gated on !singleSubmission — when the toggle is
on it never fires (the previous_submission branch wins).
3. The .fb-already card looked like a form replica (boxes around values).
Replaced with a confirmation summary: ✓-icon header ("Antwort gesendet"
+ timestamp), then a definition list with muted labels above plain
values, no input outlines. On ≥560px the rows become a two-column grid
with light dividers.
4. The "Noch eine Antwort senden" ghost button on the success card was
misleading when single_submission is on (clicking it 409s on next
submit). Hidden when singleSubmission is true; the success banner
alone now stands.
bun check 0 errors, bun test 25 pass, bun build OK.
Block repeat submissions per participant by default. No new fingerprint
column — dedup against the existing client_session_id and (client_ip,
user_agent) we already store on feedback_submissions.
Schema migration `fdbck_feedback_instances_add_single_submission` adds
single_submission BOOLEAN NOT NULL DEFAULT true (applied via Supabase MCP).
Existing instances default to true. Author opts out per-instance via the
new toggle on /admin/feedback/new and the detail Edit tab.
Server (POST /api/public/feedback/<slug>/submit): when
inst.single_submission is true, look up the most recent existing submission
matching instance_id AND (client_session_id = body.client_session_id) OR
(client_ip = req.ip AND user_agent = req.user_agent). Two separate
parameterised queries instead of a single PostgREST `.or()` filter — the
user-controlled session id has no character restriction in the schema, so
splicing it into a filter string would risk PostgREST filter injection.
Returns 409 with { error: 'already_submitted', submitted_at, display_name,
answers } so the client can render the previous answers without an extra
round-trip.
Client (/f/<slug>): on 409, replace the form with a read-only "already
submitted on <date>" card listing the previous answers per question. Reuses
question shape via a small summariseSubmittedAnswer() helper covering all
six question types including date_ranked_choice rating maps. No submit
button on the read-only view; live results polling still kicks off.
Admin schemas (InstanceCreate + InstanceUpdate) accept the new
single_submission boolean. POST/PATCH endpoints persist it.
bun check 0 errors, bun test 25 pass, bun build OK.
m's complaint: "current mode just ranked looks bad". Replace the per-option
"5 horizontal bar rows" mini-chart with two complementary views the user
toggles between, calendar default.
Calendar (default when ≥2 options):
- Horizontal day strip from min(start) to max(start), one cell per day.
- Spans > 30 days suppress empty days; otherwise contiguous (incl. blanks).
- Each day cell stacks one coloured slot per option starting that day —
multiple options on the same day stack vertically inside the cell.
- Slot colour = mean rating on a red→amber→green gradient (1 → 3 → 5).
Linear interp between #ef4444, #f59e0b, #16a34a; empty days fall back to
--color-bg-secondary. Slot shows time range, mean, and rating count.
- Hover (`title`) gives the full date, label, mean, and count for the day.
Bars (toggle, also the only view for single-option questions):
- One row per option, sorted by aggregator (mean desc → 5-count → 4-count
→ total → id). Per row: rank chip, date+label, large coloured avg,
single horizontal stacked bar with one segment per non-zero rating
bucket (segment colour matches the bucket's rating on the same r→a→g
scale), total count.
- Empty rows say "Keine Bewertung" instead of an empty bar.
Bundled fix: `summarizeAnswer()` on the admin Responses tab now renders
date_ranked_choice answers as e.g. "3.5 avg (4 rated)" instead of the
default `[object Object]` String() fall-through.
bun check 0 errors, bun test 25 pass, bun build OK.
m: "The difference between our form and the live chat needs to be more
prominent. I think we should style it more like a chat, too — maybe
separate into two columns — Form on the Left, Chat on the Right. And
chat messages more... like a chat on the phone. And we colorize different
users differently."
Layout
- New .fb-shell--wide modifier (max-width 960px) on the participant
shell so the two columns breathe. .fb-participant grid: 1fr at <900px,
1fr 1fr at ≥900px. Form column on the left, chat column on the right.
Single-column flow (form-only or chat-only) just fills its column.
- Title + description + closed-line + name field stay full-width above
the columns. Live results + footer stay full-width below.
Chat bubbles
- New .fb-bubble primitive replaces the old .fb-chat__post box-with-border
pattern on /f/[slug] only (admin moderator UI keeps .fb-chat__post).
- My posts: right-aligned, --color-primary background, white text, no
border. Others: left-aligned, --color-bg-secondary background, 3px
left-border in the speaker's deterministic color.
- Author name: tiny + weight 500. For others, name text is the speaker's
color; for me, it's --color-primary (you stay green).
- Timestamp: HH:mm if today, dd.MM HH:mm otherwise; viewer-localised.
- Vertical density: 0.6rem gap by default, +0.5rem extra when the speaker
changes (bumps to ~1.1rem — visually distinct turn boundaries).
Per-user color
- 7-color palette (rose / orange / amber / cyan / blue / violet / pink),
greens deliberately excluded so they don't clash with --color-primary
which the viewer's own bubbles use.
- Stable hash of client_session_id maps to a palette index — same user
gets the same color across reloads.
- Color applied to: author name text, bubble left-border. No avatar
circle in v1.
Compose
- New .fb-compose at the bottom of the chat column (in flex-flow, no
position:sticky needed — bubbles take flex:1 and scroll, compose stays
pinned). Textarea + send button side-by-side.
- Single-row textarea (rows=1, max-height 10rem) grows on input.
- Enter alone sends; Shift+Enter inserts a newline; IME composition is
respected (e.zh ne IME's confirm Enter doesn't submit).
- Placeholder includes the keyboard hint in German.
Auto-scroll
- $effect on posts.length scrolls the bubbles container to the bottom
on append + initial load with behavior:'smooth'. Replaces the old
imperative queueMicrotask calls inside fetchPosts/postChat.
Form column
- Section H2 ("Fragebogen") dropped — column boundary already names the
section. Submit button: full-width on mobile, auto-width on desktop.
- Submit-success banner now uses .fb-form-banner--success class
(replaces inline style hardcoded green).
CSS hygiene
- Removed orphaned .fb-chat__list / .fb-chat__form / .fb-chat__form-actions
/ .fb-chat__empty (only the participant page used them; new bubble
primitives replace them). Admin-side .fb-chat / .fb-chat__post / etc.
kept intact for the moderator UI.
Anti-scope honored: admin pages untouched, German strings unchanged,
3s polling interval kept, no new dependencies.
- FormBuilder.svelte: new `Date ranked choice` type in the type picker.
Per-option editor uses datetime-local inputs (start required, end
optional) plus an optional free-text label. Below the option list, two
optional rating-1 / rating-5 label fields and an "Allow participants to
skip individual options" toggle (default on). Local↔UTC conversion
helpers keep storage in UTC ISO 8601 while the input element shows the
author's local time.
- /f/[slug]: participant rows of `(date · optional label)` + 1-5 button
group + a "—" skip button. Required check enforces "at least one rated"
for required questions and "all rated" when allow_partial=false.
- Results.svelte: ranked list of options with mean rating, count, and a
per-option distribution histogram. Heading shows the option's local-time
date range. Sort order comes from the aggregator (mean desc + tiebreaks).
- feedback.css: layout for the new builder rows, participant rating rows
(mobile-stacks), and the ranked results list.
Refs m/fdbck#1.
- `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.
New discriminated-union variant for `FeedbackQuestionSchema`:
{ type: 'date_ranked_choice',
options: [{ id, start, end?, label? }, …],
scale?: { min_label?, max_label? },
allow_partial?: boolean }
- Times stored as UTC ISO 8601 strings (datetime with offset). Author UI
feeds them through datetime-local inputs that the browser already treats
as local time; renderer converts back to viewer-local on display.
- Rating scale is locked at 1-5 (5-point Likert) per design — the `scale`
field exposes only labels, not min/max bounds.
- Per-option ids are 1-64 chars, alphanumeric + `-`/`_`, must be unique.
- 2-50 options per question.
Submission answer union extended with a `Record<string, 1|2|3|4|5|null>`
shape for the per-option rating map (`{ opt1: 5, opt2: null }`).
Refs m/fdbck#1.
m's complaint: "I already want the visual editor/json editor switch — why
only after creating an empty form, that makes no sense". Three steps to
get to the obvious starting place — create empty, navigate to detail,
switch tab — is friction.
Mirrors the detail-page Edit-tab pattern verbatim:
- editMode / editForm / editFormJson state, plus syncJsonFromVisual /
syncVisualFromJson / switchEditMode helpers ported 1:1 from
/admin/feedback/[id]/+page.svelte. The two pages now author questions
the same way.
- Default mode: Visual, with a null editForm + "No questions yet." +
"+ Add questions" button. Clicking the button calls ensureBuilderForm()
which seeds the same { id: 'q1', label: 'Question 1', type: 'short_text',
required: false } stub the detail page seeds.
- JSON mode unchanged: textarea + "Insert sample" + helper text.
- Submit logic resolves form_definition from whichever mode is active
(mirrors detail-page saveEdits parsedForm branch).
- Disclosure framing kept ("Add questions now (advanced)") — collapsed by
default so the title-+-chat-only path stays uncluttered.
Reuses FormBuilder.svelte directly. No new component, no new dep.
POST /api/admin/feedback contract unchanged.
Final polish + verification commit. Tokenises the last hard-coded
margin in .fb-question (1.25rem → var(--space-4)) so the spacing scale
introduced in commit 1 is the single source of truth. Visually
identical (1.25rem === --space-4); the payoff is that any future
adjustment to the field-gap token propagates here automatically.
Optimistic status toggle was implemented inline in the list (commit 4)
and detail (commit 5) pages with a revert-on-failure path — no further
sweep needed.
Dark-mode QA — verified at 375 / 1024 / 1440 widths via headless
Chromium with --enable-features=WebContentsForceDark + --force-dark-mode
(token cascade confirmed firing: the rendered gradient matches the dark
--gradient-bg in feedback.css, not Chrome's auto-dark inversion of the
light gradient):
- / — wordmark + tagline + ghost CTA centred, dark-teal gradient ✓
- /login — vertical-centred form, white-on-dark fields, primary CTA ✓
Admin pages and /f/[slug] need post-deploy verification once head
merges + deploys this branch — they require auth + a real form slug
which the local preview can't supply. The token cascade is shared
with these pages so visual regressions are unlikely; functional QA
of the optimistic toggle, ⋯ menu click-outside, and share-strip
short-link flow should happen against fdbck.msbls.de.
- Drop the inline-label name row (.fb-name-row) — the only
inline-label-with-input pattern in the app. Replace with the same
stacked-label .fb-question pattern every other field uses.
- Closed-state: switch from amber .fb-banner--closed to a quiet neutral
.fb-closed-line (italic muted text between two thin border lines).
Closed is a state, not an alert.
- Mine-post chat bubble: drop the full primary-coloured border in favour
of a 3px left-border-only accent. Less loud, still recognisable.
- Footer "fdbck.msbls.de" wraps as a permalink to / with hover affordance.
German strings on this page are unchanged per m's override (m/fdbck#3
will handle proper i18n separately).
The header was the densest surface in the app: 8 controls in a single
flex-wrap row plus a separate Share section bolted between header and tabs.
This commit collapses both into something readable.
Header:
- 8 visible controls → 3 visible. Status pill is now clickable and toggles
between open/closed (optimistic), replacing the Close/Reopen button. The
⋯ menu absorbs Copy /f/<slug>, Export CSV, Export JSON, and a separator
before Delete (still in red). All gone from the top-row strip: the raw
/f/slug, Copy link, Preview, Close/Reopen, CSV, JSON, Delete buttons.
- Quiet text-only .fb-back-link replaces the chip-style "← All forms"
button.
- New .fb-detail-head primitive lays out title-block on the left + actions
on the right with proper flex-wrap behaviour.
Share:
- Standalone <section data-fb-share> deleted. Its job moves to a new
inline .fb-share-strip directly under the title in the header.
- Strip always shows a usable URL: short_url if it exists, else the raw
/f/<slug>. Copy + Open ↗ buttons sit alongside.
- Below the strip, a compact <details class="fb-share-strip__replace">
holds the slug input + Create/Replace button. Summary text adapts to
whether a short link already exists.
Tab body:
- Drop the inner <h2> in every tab body (the active tab pill names the
section). All four tab bodies now use .fb-tab-body for consistent top
padding (var(--space-6)).
- "X responses already received…" warning becomes a muted .fb-question__help
line, not a .fb-banner box.
- Visual / JSON toggle becomes a real .fb-segment control matching the
shape of .fb-tabs (consistency).
- Save row uses .fb-save-row with the version pill ("Current version: vN")
rendered as a quiet .fb-version-note next to the Save button instead of
decorating the H2 like before.
- Submissions table extracted to a small <style> block (.fb-detail-table)
instead of inline style="..." chunks.
Click-outside-to-close + Escape close any open ⋯ menu, mirroring the list
page. Polling, refresh, and all backend contracts unchanged.
Delete still uses confirm() per m's override — deletion remains a deliberate
two-step action, no undo toast.
m chose cards over a spacious-list pattern. They're MINIMALIST cards: subtle
.fb-card bg on the gradient page bg, no border + shadow stack, generous
internal padding, plenty of negative space between cards (1.5rem mobile,
2rem desktop). 2-up at ≥640px so they breathe without widening the shell.
Per-row simplifications:
- Drop the H2 "Your forms (N)" — the cards are the count.
- Drop the descriptive paragraph in the header — single primary CTA on the
right is the entire header.
- Card title is the link to detail. The "Edit" button becomes implicit.
- Subtitle merges mode + counts on one muted line; the "created DATE" line
and the raw "/f/<slug>" line both go away (slug is in the menu, date is
available on the detail page).
- Right side: a clickable .fb-status-pill that flips status optimistically,
next to a .fb-menu (⋯ trigger + native <details> panel) holding Copy link
/ Open / Edit / ────── / Delete.
- Optimistic status toggle: pill flips instantly, PATCH fires in background,
reverts to server state on failure. Status is reversible so this is safe.
- Delete still uses the existing confirm() modal (m's override — no undo
toast, deletion remains a deliberate two-step action).
- All inline style="..." removed except a tiny hoisted .fb-list-head style
block for the header layout.
Click-outside-and-Escape close any open ⋯ menu — added via document-level
listener in onMount, cleaned up in onDestroy.
Empty state gets generous whitespace + a primary "Create your first form"
CTA in the .fb-empty container.
- Drop the page subtitle ("Set up a feedback form, a live chat session…").
The H1 + the form below carry meaning on their own.
- Replace the chip-style back-button with a quiet text-only .fb-back-link.
- Replace the inline-checkbox-as-fb-option-row chat toggle with a proper
.fb-toggle (label-left + hint + native checkbox-right).
- Tuck the JSON-questions textarea + sample button + helper text behind a
<details> disclosure labelled "Add questions now (advanced)".
The visual builder on the detail page is the canonical path; the JSON
paste at creation time is a power-user speed-up that no longer dominates
the page. Common path now reads as 4 inputs and a button.
- Move the "Insert sample" button inside the disclosure where it belongs.
Backend untouched. /api/admin/feedback POST contract unchanged.
- /: vertical-centred narrow shell, wordmark grows to 2.5rem with -0.03em
tracking, tagline simplified to "feedback by link", single ghost CTA
to /login. Drops the redundant "this page is only reachable through a
private link" sentence (the user is already here).
- /login: vertical-centred narrow shell, drops "Admin access only."
subtitle (URL says it), error moves from .fb-banner--error block above
the button to .fb-inline-error muted text below it (no layout shift,
less alarm).
Adds the spacing scale, status-pill tokens, and a set of new utility classes
that the per-screen commits will use:
- spacing scale: --space-1 through --space-9 (single source of truth for
vertical rhythm; replaces ad-hoc rem values throughout the .svelte files)
- status pill tokens: --color-status-{open,closed}-{bg,fg} (dark-mode aware,
closed pulls from the same warning palette as .fb-banner--closed)
- .fb-shell.fb-page-narrow + .fb-page-center for vertical-centred narrow
shells (landing + login)
- .fb-back-link — quiet text-only back-link, replaces the chip-style button
- .fb-inline-error — quieter alternative to .fb-banner--error
- .fb-toggle / .fb-toggle__{text,label,hint} — label-left + checkbox-right
boolean fields (no UI library)
- .fb-status-pill / .fb-status-pill__dot / --open / --closed — clickable
pill that toggles status
- .fb-menu / .fb-menu__{btn,panel,item,divider,item--danger} — native
<details>/<summary>-based ⋯ menu, no JS framework needed
- .fb-card / .fb-card__{head,title,actions,meta} + .fb-card-grid — minimalist
card on the gradient page bg, no border + shadow stack, generous padding
- .fb-empty — generous empty state
- .fb-share-strip / __url / __placeholder / __replace — inline header strip
for the detail page, replaces the standalone Share section
- .fb-closed-line — neutral muted closed-state line for /f/[slug]
- .fb-segment / __btn / __btn--active — small segmented control matching
.fb-tabs (for inline Visual/JSON toggle on Edit tab)
- .fb-detail-head / __title / __actions, .fb-tab-body, .fb-version-note,
.fb-save-row — detail-page header + tab-body layout primitives
Also normalises:
- .fb-section margin-bottom 1.75rem → var(--space-7) (≈ 2.5rem)
- focus-ring opacity 0.15 / 0.25 → 0.2 across .fb-input + .fb-btn for a
single consistent focus treatment
No structural .svelte changes here — only CSS additions and three numeric
edits. Existing pages continue to render exactly as before; the per-screen
commits that follow consume these classes.
Per-screen audit + 6 design principles + per-screen mockups + commit-by-commit
implementation plan + 7 open questions.
Boldest moves: collapse the 5-button-per-row admin list into a hover-revealed
⋯ menu with clickable status pill; fold the standalone Share section into the
detail-page header as an inline link strip; drop the JSON-questions textarea
from /new behind a <details> disclosure so the common path reads as four
inputs and a button.
No code touched — design only. Awaiting m's go before coder shift.
Self-contained "Share" section on the admin detail page. When no short URL
exists yet: shows an optional custom-slug input + "Create short link"
button. When one exists: shows the URL with Copy + Open buttons and a
collapsed "Replace" form for picking a new slug.
Append-only — does not touch existing buttons, the icon system, or
feedback.css; uses inline styles + existing fb-* classes only, so it stays
out of dokploy's parallel button-system refactor.
.env.example documents SHLINK_URL + SHLINK_API_KEY (must be copied from the
flexsiebels.de Dokploy app config to fdbck.msbls.de before this works in
prod).
Refs m/fdbck#2.