69 Commits

Author SHA1 Message Date
mAi
bf1246d224 feat(f/[slug]): render form_definition.intro as markdown (was never displayed) 2026-05-27 21:36:56 +02:00
mAi
1f7adf6883 chore: gitignore .worktrees/ (was leaking iris's worktree as a gitlink) 2026-05-27 21:34:21 +02:00
mAi
6e334fa0f1 feat(questions): resources content block (markdown question type)
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.
2026-05-27 21:34:12 +02:00
mAi
288dfb31e8 feat(f/[slug]): render description as sanitized markdown
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.
2026-05-27 21:09:10 +02:00
mAi
c13d84d0f3 docs: architecture audit — deepening opportunities for fdbck
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.
2026-05-08 11:08:15 +02:00
mAi
7f600a5279 Merge mai/cronus/arch-phase2-questions: per-question-type module bundle (§3.A) 2026-05-07 20:32:27 +02:00
mAi
1510db5332 chore: remove dead per-type helpers in /f/[slug]/+page.svelte
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
2026-05-07 20:32:21 +02:00
mAi
5314af1d05 refactor: client-side wiring through the questions registry
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.
2026-05-07 20:31:09 +02:00
mAi
5fe4b417bf refactor: server-side wiring through the questions registry — closes the gap
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.
2026-05-07 20:26:49 +02:00
mAi
1ab6ef7f22 feat(questions): date_ranked_choice module — closes the validation gap
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.
2026-05-07 20:22:52 +02:00
mAi
fe33c8f577 feat(questions): single_choice + multi_choice modules (registry slots 5-6/7)
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.
2026-05-07 20:18:16 +02:00
mAi
913e505b02 feat(questions): scale module (registry slot 4/7)
- scale.ts — schema (extends base with min/max/min_label/max_label),
  defaultStub (1..5), isAnswerEmpty (any finite number), full ingest +
  finalise + private ScaleStatsWip accumulator (mean = _sum / count,
  drops _sum on finalise — same pattern the legacy results.ts uses,
  now owned by the type module)
- scale.input.svelte — N-button rating row with active-state class +
  optional min/max captions below
- scale.builder.svelte — min/max numeric inputs + min_label/max_label
  text inputs (4 fields, same as today's FormBuilder branch)
- scale.results.svelte — mean + count meta line + per-bucket
  histogram bars
- scale.test.ts — 11 cases covering schema accept/reject (includes
  the boundary case that schemas don't enforce min<max — that's a
  form-level invariant), isAnswerEmpty (non-numeric / NaN / finite
  numbers), ingest + finalise (histogram, mean, garbage rejection,
  null mean for empty count, _sum drop verification), CSV +
  adminCellSummary

91 server tests pass (was 80). svelte-check + bun run build clean.
2026-05-07 20:16:09 +02:00
mAi
5f345eaf4b feat(questions): short_text + long_text modules (registry slots 2-3/7)
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.
2026-05-07 20:14:19 +02:00
mAi
bfb6dc8a2c feat(questions): boolean module (registry slot 1/7)
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.
2026-05-07 20:11:37 +02:00
mAi
390bd76287 feat(questions): registry shape + types — empty, awaiting per-type modules
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.
2026-05-07 20:06:15 +02:00
mAi
a0765c9bf7 test: vitest + jsdom for Svelte component tests
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.
2026-05-07 20:04:10 +02:00
mAi
37cb874096 refactor: papercut cleanup before per-question-type bundle
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.
2026-05-07 19:58:12 +02:00
mAi
67cc71a6be Merge mai/cronus/arch-phase1: withOwnedInstance + findExistingSubmission + tests 2026-05-07 19:50:22 +02:00
mAi
6888ca5eab test(server): isHoneypotTrap helper + publicResults strip-text contract
§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.
2026-05-07 19:50:16 +02:00
mAi
993fc84e19 refactor(server): findExistingSubmission helper + extract pure feedback helpers
§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.
2026-05-07 19:47:57 +02:00
mAi
8b0f453022 refactor(server): withOwnedInstance wrapper for admin /feedback/[id] endpoints
§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.
2026-05-07 19:44:44 +02:00
mAi
bc278a87d6 Merge mai/cronus/chat-compose-grow: rows=3 + field-sizing auto-grow + shorter placeholder 2026-05-06 16:33:48 +02:00
mAi
c261b8e75b ui: chat compose grows with content + better default size
- 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.
2026-05-06 16:33:42 +02:00
mAi
18644275f1 Merge mai/cronus/participant-chat-cap: cap chat column to viewport on long forms 2026-05-06 15:46:08 +02:00
mAi
0868f8cd70 fix: cap participant chat column to viewport on long forms
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.
2026-05-06 15:46:03 +02:00
mAi
538419fb1b Merge mai/hermes/single-sub-fixes: reload-shows-summary + copy + card redesign + hide answer-again on success 2026-05-06 15:44:42 +02:00
mAi
778df213da mAi: #4 - single-submission follow-up fixes
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.
2026-05-06 15:44:36 +02:00
mAi
0135de10f6 Merge mai/hermes/single-submission: single-submission enforcement (m/fdbck#4) 2026-05-06 15:32:26 +02:00
mAi
120f0798cd mAi: #4 - single-submission enforcement (default on)
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.
2026-05-06 15:32:20 +02:00
mAi
2d0df881c9 Merge mai/hermes/date-ranked-results-viz: calendar heatmap + cleaner bars + summarizeAnswer fix 2026-05-06 14:28:48 +02:00
mAi
936713b18c mAi: date_ranked_choice results — calendar heatmap + cleaner bars
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.
2026-05-06 14:28:34 +02:00
mAi
4bb7f75104 Merge mai/cronus/participant-redesign: two-column form|chat + phone-style bubbles + per-user color 2026-05-06 14:22:53 +02:00
mAi
86c46baffb ui: /f/[slug] two-column form|chat + phone-style bubbles + per-user color
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.
2026-05-06 14:22:48 +02:00
mAi
e984f26369 Merge mai/hermes/date-ranked-choice: date_ranked_choice question type (m/fdbck#1) 2026-05-06 14:15:52 +02:00
mAi
5ef08e5930 mAi: #1 - UI: builder + participant renderer + results display
- 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.
2026-05-06 14:13:22 +02:00
mAi
439b030471 mAi: #1 - server: date_ranked_choice aggregation + export + tests
- `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.
2026-05-06 14:13:11 +02:00
mAi
91098e0965 mAi: #1 - schema: date_ranked_choice question type
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.
2026-05-06 14:13:01 +02:00
mAi
4259f16d45 Merge mai/cronus/builder-on-new: visual ↔ JSON editor toggle on /admin/feedback/new 2026-05-06 14:06:06 +02:00
mAi
3cc1efa970 ui: visual ↔ JSON toggle on /admin/feedback/new (t-fdbck-builder-on-new)
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.
2026-05-06 14:05:55 +02:00
mAi
006ecf442a Merge mai/cronus/fdbck-minimalist-ui: minimalist redesign — cards, header collapse, ⋯ menus, spacing scale 2026-05-06 12:38:28 +02:00
mAi
4ab4dc5c2d ui: tokenise .fb-question spacing + dark-mode QA notes
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.
2026-05-06 12:37:26 +02:00
mAi
d9e4bfc165 ui: minimalist /f/[slug] polish
- 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).
2026-05-06 12:31:55 +02:00
mAi
94f6ba934d ui: minimalist /admin/feedback/[id] detail — header collapse + share strip
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.
2026-05-06 12:31:00 +02:00
mAi
80f2f82ac1 ui: minimalist /admin/feedback list — cards + ⋯ menu + status pill toggle
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.
2026-05-06 12:28:21 +02:00
mAi
5080f39079 ui: minimalist /admin/feedback/new
- 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.
2026-05-06 12:27:11 +02:00
mAi
b1ee5530fd ui: minimalist landing + login
- /: 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).
2026-05-06 12:26:15 +02:00
mAi
2593122337 style: CSS foundation for minimalist UI redesign
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.
2026-05-06 12:25:29 +02:00
mAi
301cec817a docs: minimalist UI redesign proposal
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.
2026-05-06 12:16:18 +02:00
mAi
3d03ee0c85 Merge mai/hermes/fdbck-shlink-short-link: shlink integration + admin Share section 2026-05-05 23:15:26 +02:00
mAi
696b796383 mAi: #2 - admin Share section + env-var docs
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.
2026-05-05 23:13:13 +02:00