§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.
Port flexsiebels' shlink REST v3 wrapper for short-link sharing of feedback
forms. New helper `src/lib/server/shlink.ts` reads SHLINK_URL (default
https://msbls.de) + SHLINK_API_KEY from env. New auth-gated
`POST /api/admin/feedback/<id>/share` builds the long URL from
PUBLIC_SITE_URL + instance.slug, calls shlink, persists shortUrl/shortCode
on feedback_instances, and returns the updated row. Adds ShareCreateSchema
(zod) for the request body and extends FeedbackInstance with the new
columns.
DB columns short_url + short_code added via Supabase migration
fdbck_feedback_instances_add_short_url (both TEXT NULL).
Refs m/fdbck#2.
- Icon.svelte: inline lucide SVGs (apache-licensed paths copied directly,
no npm dep) — edit, copy, external-link, lock, unlock, trash, plus, eye,
eye-off, check, x, arrow-left, arrow-right, download.
- Refactored .fb-btn system in feedback.css:
* Base = primary green, fixed 2.5rem height, gap for icons, Inter font.
* .fb-btn--secondary: tinted primary-light surface that fills on hover.
* .fb-btn--ghost: subtle gray bg with border (NOT bare white) — addresses
m's "no background color" complaint. Dark-mode override included.
* .fb-btn--danger: red, lifts shadow on hover.
* .fb-btn--sm / --lg: 2rem / 3rem heights with proportional padding.
* .fb-btn--icon: square icon-only variant.
* Focus-visible ring via box-shadow on the green primary tint.
- app.html: preconnect + load Inter (400/500/600/700) from Google Fonts.
.fb-page now stacks 'Inter' first.
- Applied icons + the right variant on every button across admin chrome
+ login per the brief table:
* list row: Edit (secondary, edit), Copy link (ghost, copy), Open
(ghost, external-link), Close/Reopen (ghost, lock/unlock),
Delete (danger, trash) — all --sm so the row breathes.
* list header CTA "+ New form" (primary, plus).
* /admin/feedback/new: back link (ghost --sm, arrow-left), Insert
sample (secondary --sm, plus), Create form submit (primary, check).
* detail header: back link, Copy link, Preview, Close/Reopen
(secondary), CSV/JSON exports (ghost, download), Delete (danger,
trash) — all --sm.
* detail chat: hide/show buttons promoted to .fb-btn--ghost --sm
with eye/eye-off icons; killed the inline-styled bare button.
* detail edit tab: Visual/JSON toggle now flips between --secondary
(active) and --ghost (inactive) with no inline style overrides;
Save (primary, check); "+ Add questions" (secondary --sm, plus).
* login: Sign in keeps primary, gains arrow-right hint on the right.
- FormBuilder: Add-option / type-add buttons reduced to --sm + plus
icon. Remove-question / remove-option icon-buttons get the trash/x
lucide SVG. Killed the redundant CSS overrides on these
(.fb-builder__add-option / .fb-builder__add .fb-btn) since
.fb-btn--sm now does the sizing.
Acceptance: no inline button overrides remain on .fb-btn instances; all
admin row heights are consistent (2rem); ghost buttons have the tinted
surface; dark mode handled via @media block.
Per-row action bar on the list page:
[Edit] [Copy link] [Open] [Close|Reopen] [Delete] — Delete confirms then
DELETE + invalidateAll(); Close/Reopen PATCHes status, no confirm; per-row
error banner.
Full English rewrite of admin chrome (list + detail + builder), login,
landing. Drop dev jargon — "instance" / "slug" / "schema" / docs/plans
references gone. Sample SAMPLE_FORM content also translated to a
session-feedback example. Participant /f/<slug> stays untouched (author-
supplied content). Results.svelte stays as-is too — shared with the
participant page where the surrounding chrome is German.
Tab strip on /admin/feedback/<id> restyled as a segmented pill bar
(.fb-tabs / .fb-tab / .fb-tab--active). Active tab gets the green
primary-light background + bolder text + radius-md, hover lifts to white.
Earlier tabs were nearly invisible.
Split create form to its own route /admin/feedback/new (page + auth-only
+page.server.ts mirroring the list loader). List page now shows just the
form list with a "+ New form" CTA in the header.
- html/body reset (margin 0, bg + color via tokens, fill viewport) — kills
the white user-agent frame around the dark page in dark mode.
- Replace --fb-* tokens with the flexsiebels variables.css token set
(--color-*, --radius-*, --shadow-*) and keep --fb-* aliases pointing at
them so existing class names work without rewriting.
- Page background is now the flexsiebels green gradient (light mode) and
a charcoal→teal gradient (dark mode).
- Buttons: green primary with shadow + active-press transform, ghost
variant with proper border + hover.
- Inputs/textareas: rounded-md, focus ring via box-shadow on
--color-border-focus instead of bare outline.
- Scale buttons: hover hint + green active state with shadow.
- Chat posts + builder cards: white surface with shadow-sm.
- Banners: subtle elevation; dark-mode variants for closed/error so they
read on the dark gradient.
- Headings: tighter letter-spacing, slightly larger h1.
System dark mode (prefers-color-scheme) still toggles automatically; the
participant page sections stay flat (no re-introduced frame).
Migration: + fdbck.feedback_instances.live_results_enabled bool default false
+ fdbck.feedback_submissions.form_snapshot jsonb (frozen form per submission)
Schemas (moved $lib/server/schemas.ts → $lib/schemas.ts so the form-validation
Zod runtime can be reused on the client):
- form_definition.version: "0.YYMMDD" (today = 0.260505) with .b/.c suffix
for same-day re-edits when older snapshots already use that day
- live_results_enabled on Create + Update DTOs
Server:
- submit/+server: writes the parsed form_definition into form_snapshot so
results stay queryable after the form is later edited
- admin POST: stamps todayVersion() on first save
- admin PATCH: stampVersion() keeps current version while no submission has
it yet; otherwise advances to today (or .b/.c)
- new $lib/server/results.ts: pure aggregation + version helpers
(scale → histogram + mean, choices → counts + other_count for vanished
options, boolean → yes/no, text → list of free-text answers)
- new GET /api/public/feedback/<slug>/results: gated on live_results_enabled,
strips free-text answers (count-only) for participant-side display
- admin GET + page loader return aggregated results alongside submissions
UI:
- Results.svelte component (shared admin/participant) — CSS bar charts,
no external lib
- FormBuilder.svelte — add/remove/reorder/edit questions, type switch,
options/scale config; visual ↔ JSON toggle in admin Edit tab keeps both
views in sync
- admin detail: new "Ergebnisse" tab with version stamp, "live_results"
checkbox in Edit tab, info banner about version bumps when submissions exist
- /f/<slug>: after submit (and only if live_results_enabled), polls
/results every 5s and renders <Results /> below the form
- supabase-js with .schema('fdbck') returns JSONB columns as JSON-encoded
strings; getInstanceBySlug + getInstanceById + admin list now JSON.parse
via a shared parseFormDefinition helper, so FeedbackFormDefinitionSchema
sees an object and questions actually render.
- footer: 'flexsiebels.de · per-Link Feedback' → 'fdbck.msbls.de'.
- .fb-section: drop the white card frame (transparent bg, no border, no
border-radius) — sections now flow flat on the page.
- README.md: stack, run-locally, test/check/build, structure tree, data
model summary, anti-abuse layers, scope notes, issue origin pointer.
- docs/plans/feedback-feature.md: copied verbatim from flexsiebels for
self-containment (single source of truth in this repo from now on).
Mirrors msbls.de pattern, simplified (no mbrian-core submodule clone).
UID note: oven/bun:1-alpine has a built-in 'bun' user at UID/GID 1000 and
`addgroup -u 1000` on top of it breaks the build silently. mExDraw#14
(commit fc62b9c) lost ~4 weeks of Dokploy deploys to that. Comment in the
Dockerfile so the next person doesn't trip over the same.
Production build verified locally: vite build ✓ (4.08s).
Direct port from flexsiebels worktree. Imports getInstanceBySlug from
$lib/server/feedback (which uses fdb()) — schema rename happens at the
helper level, page code is identical.
Behaviour:
- LocalStorage: feedback:display_name (global) + feedback:session:<slug>
- 3s polling /posts?since=<latest_ts>; auto-scroll on new
- Hidden posts: '(Beitrag entfernt)' for others; own session sees body + note
- Honeypot 'company' input (CSS-hidden, aria-hidden)
- 423 → closed banner; 429 → rate-limit message; required-validation client+server
- noindex meta + no-referrer
- Question types: short_text, long_text, single_choice, multi_choice, scale, boolean
Root +layout.svelte already gives the naked shell (no sidebar/footer/bottom-nav)
so the +layout@.svelte reset trick is unnecessary here.
bun run check: 0 errors, 5 warnings (known false-positive 'data captured at
init' on $state — data from server load doesn't change client-side; same warning
pattern as flexsiebels).
Public (slug-gated, auto-allowlisted):
- GET /api/public/feedback/[slug] — instance config
- POST /api/public/feedback/[slug]/submit — form submission (honeypot, rate-limit, required-validation, 423 if closed)
- GET /api/public/feedback/[slug]/posts — chat polling (?since=, hides body of moderated posts)
- POST /api/public/feedback/[slug]/posts — new chat post (honeypot, rate-limit, 423 if closed)
Admin (requireAuth, owner-scoped):
- GET/POST /api/admin/feedback — list/create
- GET/PATCH/DELETE /api/admin/feedback/[id] — detail/update/delete (PATCH closes/reopens, sets closed_at)
- POST /api/admin/feedback/[id]/posts/[post_id]/hide — toggle hidden flag
- GET /api/admin/feedback/[id]/export?format=csv|json — single-file dump
Auth:
- POST /api/auth/sign-in — Supabase email+password, sets access+refresh cookies
- POST /api/auth/sign-out — clears cookies
bun run check: 0 errors, 0 warnings.
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).