Merge mai/cronus/fdbck-minimalist-ui: minimalist redesign — cards, header collapse, ⋯ menus, spacing scale

This commit is contained in:
mAi
2026-05-06 12:38:28 +02:00
8 changed files with 1630 additions and 248 deletions

706
docs/plans/ui-redesign.md Normal file
View File

@@ -0,0 +1,706 @@
# fdbck — minimalist UI redesign
**Status:** design proposal, not yet approved.
**Branch:** `mai/cronus/fdbck-minimalist-ui`.
**Author:** cronus, 2026-05-06.
m's brief: *"While our app is quite simple, the layout of it seems overly complex
and crowded. I want clearer separation (by negative space) between the different
elements and smarter control buttons for a smooth UX."*
The app is already small (1 stylesheet, 2 reusable components, 5 routes). The
problem isn't features — it's that the existing screens fight minimalism with
*always-visible action rows*, *redundant copy*, *inline `style="..."` patches*,
and *tight vertical rhythm*. This doc proposes per-screen edits that hold
features constant but trade visible buttons for whitespace and progressive
disclosure.
---
## 1. Audit
### 1.1 `/` (landing)
```
┌────────────────────────────────────────┐
│ fdbck │ ← 1.75rem h1
│ Private feedback forms and live chat… │
│ │
│ This page is only reachable through… │
│ [ Admin sign-in ] │ ← ghost button
└────────────────────────────────────────┘
```
- Already close to minimalist — 20 lines of markup, two paragraphs, one ghost
button. Mostly fine.
- The wordmark `fdbck` is the brand moment but renders at the same size as
every other page h1 (`1.75rem`). It should *feel* like a logo on the landing
page, not a generic heading.
- Two paragraphs ("Private feedback forms…" / "This page is only reachable…")
partly say the same thing. The second one is a near-tautology — the user is
already on the page, so "you got here through a private link" is implicit.
- Vertical rhythm is tight: `1.5rem` between subtitle and section, then the
button sits right under one short sentence.
- Tiny: a marketing-blank landing for a tool whose CTA is "sign in" feels
hollow. Either lean further into emptiness (just the wordmark + sign-in) or
add one line of confidence-building text. We'll lean into emptiness.
### 1.2 `/login`
```
┌────────────────────────────────────────┐
│ Sign in │
│ Admin access only. │
│ │
│ Email [______________] │
│ Password [______________] │
│ [ Sign in → ] │
└────────────────────────────────────────┘
```
- "Sign in" + "Admin access only" is mild redundancy — the page is the form.
- Error banner sits *between* the password field and the submit button, which
pushes the button down on error and feels like a layout glitch. Better
position: above the form (page-level error) or beneath the button as an
inline string.
- Two fields, one button, no dark patterns — already minimalist. Just needs
more vertical breathing room and a quieter subtitle (or none).
### 1.3 `/admin/feedback` (list)
```
┌──────────────────────────────────────────────────────────────────┐
│ Feedback forms [ + New form ] │
│ Collect feedback through forms or live chat. Share a private… │
│ │
│ Your forms (4) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Title (link) [Form+chat] [open] │ │
│ │ 12 responses · 7 messages · created 2026-05-01 │ │
│ │ /f/long-slug-shown-raw │ │
│ │ [Edit] [Copy link] [Open] [Close] [Delete] ← 5 buttons │ │
│ └──────────────────────────────────────────────────────────┘ │
│ … │
└──────────────────────────────────────────────────────────────────┘
```
- **The biggest offender.** Five always-visible buttons per row (Edit, Copy
link, Open, Close/Reopen, Delete) wrap onto multiple lines on narrow widths.
Even on desktop they read as a noisy strip.
- Each row has *border + shadow + bg + padding + status pill + mode pill +
raw slug + meta line + button strip*. Nine visual objects per row.
- Inline `style="…"` everywhere (`+page.svelte:108-156`). Should be classes in
`feedback.css` so dark-mode and rhythm tokens stay consistent.
- The raw `/f/<slug>` line is shown but has no purpose for the admin — they
use Copy link or Open. It's just text noise.
- Header repeats the page title in the body (`<h1>Feedback forms</h1>`) and
then the section heading (`<h2>Your forms (N)</h2>`). The H2 mostly
duplicates the H1, just with a count.
### 1.4 `/admin/feedback/new`
```
┌──────────────────────────────────────────────────────────────────┐
│ [← All forms] │
│ Create a new form │
│ Set up a feedback form, a live chat session, or both. You'll… │
│ │
│ Title [______________________________] │
│ Description [______________________________] │
│ ☑ Enable live chat │
│ Questions (JSON, optional) [ + Insert sample ] │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ { … 14-row JSON textarea, monospace … } │ │
│ └──────────────────────────────────────────────────────────┘ │
│ Leave empty for chat-only feedback. You can edit questions… │
│ │
│ [ ✓ Create form ] │
└──────────────────────────────────────────────────────────────────┘
```
- The JSON textarea is a *power-user escape hatch* dropped into the same
surface as a beginner-friendly title/description form. It dominates the
page (14 rows, monospace font), even though the visual builder on the
detail page is the recommended path.
- "Insert sample" sits *to the right* of the label, inside the field's
header — visually clever but also noise.
- The chat checkbox is a single inline item floating in its own block. It
looks afterthought-y. (It also reuses `.fb-option-row` styling, which is
meant for question options.)
- Helper text under the JSON is good, but it's the *third* descriptive
paragraph on the page (`<p>Set up a feedback form…</p>`,
`placeholder="…"`, `<div class="fb-question__help">`).
### 1.5 `/admin/feedback/[id]` (detail)
```
┌────────────────────────────────────────────────────────────────────────┐
│ [← All forms] │
│ My session feedback │
│ Some optional description text shown if present. │
│ [open] /f/long-slug [Copy link] [Preview] [Close] [CSV] [JSON] [Delete] ← 8 chips/buttons in one row
│ │
│ ┌── Share ─────────────────────────────────────────────────────────┐ │
│ │ Memorable short link — resolves to /f/long-slug. │ │
│ │ https://msbls.de/vote [ Copy ] [ Open ↗ ] │ │
│ │ ▸ Replace with a different short link │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ [ Chat (3) ] [ Results (12) ] [ Responses (12) ] [ Edit ] │
│ …active tab body… │
└────────────────────────────────────────────────────────────────────────┘
```
- **Header is the densest surface in the app.** Eight clickable controls in a
flex-wrap row: status pill, raw slug, Copy link, Preview, Close/Reopen,
CSV, JSON, Delete. On a 1024px viewport this wraps onto two lines.
- "Copy link / Preview" in the header *and* a separate Share section *and*
the slug shown raw — three different ways to access the same URL.
- CSV / JSON download buttons sit between Close and Delete — they're not
in the same gravitational tier (export is benign, Delete is nuclear), but
they look identical (both `fb-btn--ghost fb-btn--sm`).
- The Share section (recently added) was bolted onto the layout rather than
designed into it. It sits between header and tabs, breaking the "header →
tab → tab body" mental model.
- The Edit tab's form has its own internal mini-toolbar (Visual / JSON
toggle), which is correct but visually clashes with the parent tab strip
one screen up.
- Polling `setInterval(refresh, 5000)` is right; no UX change needed there.
### 1.6 `/f/[slug]` (participant)
```
┌──────────────────────────────────────────┐
│ Title │
│ Optional description │
│ [⚠ closed banner if closed] │
│ │
│ Dein Name (optional): [Anonym______] ← inline label + full-width input collide
│ │
│ ┌── Live-Feedback ──────────────────┐ │
│ │ scroll list of posts │ │
│ │ [textarea] │ │
│ │ [ Senden ] │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌── Fragebogen ─────────────────────┐ │
│ │ q1 [...] │ │
│ │ q2 [...] │ │
│ │ [ Absenden ] │ │
│ └───────────────────────────────────┘ │
│ │
│ fdbck.msbls.de │
└──────────────────────────────────────────┘
```
- Already simple. Needs *more* breathing room between Chat and Form sections
— they read as two equally-weighted siblings, but in practice one of them
is what the user came for.
- The `Dein Name (optional):` label sits inline left of a full-width input
(`fb-name-row`), while every other label in the participant form is on
its own line above the input. The inline-label pattern is the only one
of its kind on this page — kill it.
- Footer `fdbck.msbls.de` is fine as a quiet trust signal.
- Closed banner uses `--color-warning` palette which reads as alert; for a
closed-form *neutral* state, a quieter visual is better.
- Locale mix: admin pages use English (`Sign in`, `Edit`, `Delete`), but the
participant page is German (`Senden`, `Absenden`, `Fragebogen`). Probably
intentional (admins are us, participants are German-speaking) — flagging
it so we don't accidentally homogenise during the redesign. **Not in
scope to change.**
---
## 2. Principles
The following six rules apply to every screen. They are the lens for §3.
1. **Whitespace over dividers.** Replace borders/shadows/dividers with
generous vertical space. The rule of thumb: if a card is bordered on a
page that's already on a coloured `--gradient-bg`, the card competes with
the page background — drop the border, keep the radius+padding, lean on
negative space. Spacing scale: `0.5rem` (within a row), `1.25rem` (between
form fields), `2.5rem` (between sections), `4rem` (above page footer).
2. **One primary action per page.** Per screen, exactly one button uses the
solid `.fb-btn` (filled green). Everything else is `--secondary` or
`--ghost` or icon-only. Today most screens have 28 solid-or-near-solid
actions competing for attention.
3. **Smarter controls — collapse rows.** Anywhere we currently render 3+
buttons in a strip, the redesign keeps **one** primary inline (or zero —
"the row itself is the button") and tucks the rest behind a `⋯` menu
(`<details>` summary or a small popover). Status flips become pill
toggles instead of buttons. Destructive actions live *only* inside the
`⋯` menu, never as a top-level visible button.
4. **Visual hierarchy via type weight, not borders.** Inter is loaded — use
weight `700` for page-h1, `600` for section-h2, `500` for form labels,
`400` for body. Tracking `-0.02em` on headings (already in the CSS for
h1) extended to h2. This lets us drop a lot of decorative chrome.
5. **Optimistic UX.** Delete a row → row disappears immediately, with an
undo toast for ~6s. Toggle status → pill switches instantly, network
call in background. Errors revert the optimistic change and show a small
inline message. We already have Svelte 5 `$state` everywhere, so this
is plumbing, not architecture.
6. **No inline `style="..."` for layout.** Every inline style currently in
the .svelte files moves to a class in `feedback.css`. Two reasons: dark
mode lives in `@media (prefers-color-scheme: dark)` rules and inline
styles bypass it; and inline styles defeat the spacing-scale rule (1).
Optional principle 7 (suggest, but waiting for m): **list, not cards.** A
naked padded list with hover-revealed actions beats card-grids for a small N
(≈ <50 forms). Keeps the page feeling like *content*, not a dashboard.
---
## 3. Per-screen redesigns
### 3.1 `/` (landing)
```
┌────────────────────────────────────────┐
│ │
│ │
│ fdbck │ ← 2.5rem, weight 700, tracked -0.03em
│ feedback by link │ ← muted, 1rem, weight 400
│ │
│ │
│ [ Admin sign-in → ] │ ← single ghost button, sized fb-btn--lg
│ │
└────────────────────────────────────────┘
↑ massive vertical centering
```
- Centre the page vertically (max-width 480px, `min-height: 100vh`,
`display: grid; place-items: center;`).
- Drop the "This page is only reachable through a private link…" sentence
if a participant lands here they're not lost; the wordmark + sign-in is
enough.
- Wordmark grows to `2.5rem`, weight `700`, tracking `-0.03em`. Subtitle
shrinks to `1rem` muted.
- Single CTA, `fb-btn--lg fb-btn--ghost` with the existing `arrow-right`
icon (already used on login submit).
### 3.2 `/login`
```
┌────────────────────────────────────────┐
│ │
│ Sign in │ ← drop "Admin access only"
│ │
│ Email │
│ [______________________] │
│ │
│ Password │
│ [______________________] │
│ │
│ ⓘ inline error appears here, muted │
│ │
│ [ Sign in → ] │
└────────────────────────────────────────┘
```
- Drop the "Admin access only." subtitle (the URL says it).
- Vertical-centre the form (same grid trick as landing).
- Inline error: render as a small muted line *below* the submit button
rather than an `fb-banner--error` block above it. Less alarming, no
layout shift.
- Keep the existing primary `fb-btn` with arrow-right icon.
### 3.3 `/admin/feedback` (list) — **the biggest change**
```
┌────────────────────────────────────────────────────────────────────┐
│ Forms [ + New ] │ ← terse h1, primary CTA
│ ───────── │
│ │
│ My session feedback ●open ⋯ │ ← row hover-reveals ⋯
│ Form + chat · 12 responses · 7 messages │
│ │
│ ┌── 2.5rem of pure whitespace ──┐ │
│ │
│ Sprint 4 retro ○closed ⋯ │
│ Form · 0 responses │
│ │
│ ┌── 2.5rem of pure whitespace ──┐ │
│ │
│ Quick chat ●open ⋯ │
│ Chat · 21 messages │
└────────────────────────────────────────────────────────────────────┘
```
**Row anatomy:**
- The whole row title is a link goes to detail (today's "Edit" button
becomes implicit).
- Subtitle: mode + counts only. **Drop** the raw `/f/<slug>` line, the
`created DATE` ("created" word), and the mode pill fold mode into the
subtitle text ("Form + chat · 12 responses").
- Right side: a status dot+label (●open / closed) that *is the toggle*
click flips it (optimistic). One control replaces today's
pill-+-Close-button pair.
- Trailing `⋯` menu (`<details>` summary or popover) opens:
- Copy link
- Open
- Edit (explicit jump, even though row is also a link)
- ──────
- Delete (red)
- Drop `border + shadow + bg` on the row container. Rely on row-padding
+ `2.5rem` gap between rows. Hover state: faint `--color-bg-tertiary`
background, no border.
- Mobile (`< 640px`): keep the `⋯` always visible; on desktop reveal on
hover for a calmer default state.
**Header:**
- Drop the descriptive paragraph ("Collect feedback through forms…"). The
user already knows what fdbck is.
- H1 reads "Forms" (terser than "Feedback forms", since we're already at
`/admin/feedback`).
- Drop the H2 "Your forms (N)" the count moves into the H1 area as a
small muted number, or drops entirely (the rows themselves are the
count).
- Keep one solid `+ New` button on the right.
**Empty state:** generous whitespace + "No forms yet create your first
one." + the same `+ New` button as primary CTA.
**Optimistic delete:** after confirming, row disappears immediately;
toast at the bottom: "Form deleted. [Undo]" for 6s.
### 3.4 `/admin/feedback/new`
```
┌────────────────────────────────────────────────────────────────────┐
│ ← Forms │ ← terse back-link, no chip
│ │
│ New form │
│ │
│ Title │
│ [______________________________________________] │
│ │
│ Description (optional) │
│ [______________________________________________] │
│ │
│ ☐ Enable live chat │
│ │
│ ▸ Add questions now (advanced) │ ← collapsed by default
│ │
│ [ ✓ Create form ] │
└────────────────────────────────────────────────────────────────────┘
```
- Title + Description + chat checkbox are the *common path*. They occupy
the full page width without any JSON noise.
- The JSON textarea moves behind a `<details>` disclosure labelled "Add
questions now (advanced)". Inside the disclosure we keep:
- the `Insert sample` button (now sits *inside* the disclosure, where it
belongs)
- the textarea
- the helper paragraph
- Rationale: the visual builder on the detail page is the canonical path
for adding questions; offering JSON paste at *creation* is power-user
speed-up, not the default. By collapsing it, the new-form page reads
like 4 inputs and a button.
- Replace the back-link button with a plain `← Forms` text-link in muted
weight 500, no chip styling.
- Drop the page subtitle ("Set up a feedback form…"). The label "New form"
+ the form below carry the meaning.
- Move the chat toggle from a faux-`fb-option-row` checkbox to a proper
`.fb-toggle` with a clean label-on-the-left layout (new class small
CSS addition).
### 3.5 `/admin/feedback/[id]` (detail) — **the second-biggest change**
```
┌────────────────────────────────────────────────────────────────────────┐
│ ← Forms │
│ │
│ My session feedback ●open ⋯ │ ← title + clickable status pill + ⋯
│ Some optional description shown if present. │
│ │
│ msbls.de/vote [ Copy ] [ Open ↗ ] [ Replace ] │ ← share-link strip, INLINE in header
│ │
│ ───────────────────────────────────────────────────────────────── │ ← whitespace, no border
│ │
│ [ Chat (3) ] [ Results (12) ] [ Responses (12) ] [ Edit ] │
│ │
│ …active tab body, with 2.5rem top-padding… │
└────────────────────────────────────────────────────────────────────────┘
```
**Header collapse — 8 controls → 3 visible:**
| today's control | redesign |
| ------------------------------------ | ---------------------- |
| status pill (display only) | status pill, clickable to toggle (optimistic) |
| `/f/slug` raw text | dropped (the share strip below has the link) |
| Copy link button | inside share strip |
| Preview button | inside share strip ("Open ↗") |
| Close / Reopen button | merged into status pill |
| CSV button | inside `⋯` menu |
| JSON button | inside `⋯` menu |
| Delete button | inside `⋯` menu (red, last) |
`⋯` menu contents (in order, with separators):
1. Copy link (when the share strip is hidden on mobile)
2. Export CSV
3. Export JSON
4. ──────
5. Delete (red)
**Share section disappears as a separate block.** The short URL is shown
inline in the header as one strip:
- If `inst.short_url` exists: show it + Copy + Open + Replace (Replace
opens a small inline form *under* the strip same `<details>` pattern
as today, but compact).
- If it doesn't: show "No short link · [ Create one ]" click reveals
the slug input + create button.
This kills today's awkward "section between header and tabs" structure
and folds share into the place where the user expects "where do I share
this".
**Tabs unchanged in count and labels.** They stay: Chat / Results /
Responses / Edit. Selection style stays the segmented-pill (`fb-tabs` /
`fb-tab--active`). Whitespace between tab strip and tab body grows from
`1.25rem` to `2.5rem` so the tab body breathes.
**Edit tab inline polish:**
- Drop the duplicate `<h2>Edit · v3</h2>` heading inside the tab the
active tab pill already says "Edit". Save the version pill for a quiet
spot next to the Save button.
- The `Visual` / `JSON` toggle becomes a small segmented control in the
same shape as the tab pills (consistency).
- Save button is the page's primary action *while* this tab is active.
No other solid button on the page in that state.
**Banner about responses-already-received** (`{submissions.length}
responses already received…`) becomes a single muted line above the form,
not a banner box.
### 3.6 `/f/[slug]` (participant)
```
┌──────────────────────────────────────────┐
│ │
│ Title │ ← h1, room above
│ Optional description │
│ │
│ ⓘ closed (only if closed, neutral) │ ← muted not yellow when closed
│ │
│ ───────────────────────────────────── │ ← 3rem gap
│ │
│ Live-Feedback │
│ ┌── chat list ──────────────────────┐ │
│ │ … │ │
│ └───────────────────────────────────┘ │
│ [ textarea (full width) ] │
│ [Senden]│
│ │
│ ───────────────────────────────────── │ ← 3rem gap (same as above)
│ │
│ Fragebogen │
│ q1 │
│ q2 │
│ │
│ [ Absenden ] │
│ │
│ │
│ fdbck.msbls.de │
└──────────────────────────────────────────┘
```
- **Drop the inline-label name row.** Replace with a normal labelled field
matching the rest of the form:
```
Dein Name (optional)
[Anonym__________________________]
```
Or move it *below* the chat/form sections as a footer-style "Du
schreibst als: Anonym ✎" with click-to-edit. (Suggested — see open
question #5.) For v1 keep it at top but normalise its layout.
- Increase section gap from `1.75rem` to `3rem` between the chat block
and the form block, so they read as two distinct activities.
- Closed banner: switch from amber `--color-warning` palette to a quiet
neutral muted style (1px top border + small italic line) — closed is a
state, not an alert.
- Drop the `border-color: var(--color-primary)` on `.fb-chat__post--mine`
in favour of a subtle left-border-only accent (3px, `--color-primary`).
The current full pill-with-coloured-border treatment is loud.
- Footer `fdbck.msbls.de` → wrap as a permalink to `/`, very muted.
---
## 4. Implementation plan
The redesign is *only* CSS + small `.svelte` markup edits. No new
components, no new dependencies, no schema changes. Suggested commit order
(each commit is independently mergeable and testable):
### Commit 1 — CSS foundation: spacing scale + typography rhythm
- `src/lib/styles/feedback.css`:
- Add a spacing scale as CSS custom properties (`--space-1` through
`--space-7`, or named `--gap-row / --gap-field / --gap-section /
--gap-foot`).
- Bump `.fb-section` margin-bottom from `1.75rem` → `--gap-section`
(≈ `2.5rem`).
- `.fb-header h1` weight stays 700; `.fb-section h2` weight bumps from
`600` → `600` (no change) but tracking `-0.01em` already there.
- Add `.fb-page-narrow` (max-width `480px`) and `.fb-page-center`
(vertical grid centre) modifiers for landing/login.
- Add `.fb-toggle` for label-left+switch-right boolean fields.
- Add `.fb-row` for spacious list rows (no border, no shadow, hover
bg) plus `.fb-row__title`, `.fb-row__meta`, `.fb-row__actions`.
- Add `.fb-status-pill` (clickable open/closed pill).
- Add `.fb-menu` (the `` dropdown — implemented as `<details>` +
`<summary>` + a positioned panel).
- Add `.fb-share-strip` (the inline header share row in 3.5).
- No structural Svelte changes in this commit — verify in the browser
that the existing pages still render fine since added classes are
unused additions.
### Commit 2 — landing + login (cheap wins)
- `src/routes/+page.svelte`: drop the second paragraph; wrap shell in
`fb-page-center fb-page-narrow`; bump wordmark size.
- `src/routes/login/+page.svelte`: drop the "Admin access only."
subtitle; wrap in `fb-page-center fb-page-narrow`; move error from
`fb-banner--error` block to a small inline muted line.
- Verify both routes in the browser at 375px / 1024px / 1440px.
### Commit 3 — `/admin/feedback/new`
- `src/routes/admin/feedback/new/+page.svelte`:
- Drop the page subtitle.
- Replace the chat-checkbox block with `.fb-toggle`.
- Wrap "Questions (JSON, optional)" + sample button + textarea +
helper inside a `<details>` with summary "Add questions now
(advanced)".
- Remove inline `style="..."` on the back-link block; replace with a
`.fb-back-link` class (text-only, muted).
### Commit 4 — `/admin/feedback` (list) — biggest change, isolated commit
- `src/routes/admin/feedback/+page.svelte`:
- Drop the H1 paragraph.
- Drop the H2 "Your forms (N)".
- Replace each row (today's bordered card with 5 buttons) with the
`.fb-row` pattern from commit 1: title is a link to detail,
subtitle merges mode + counts, right side is `.fb-status-pill`
(toggle) + `.fb-menu` trigger.
- Move all inline `style="..."` to classes (no new feature).
- Add the `` menu component inline using `<details>` — no JS
framework needed; click-outside-to-close via a small `addEventListener`
in `onMount`.
- Add optimistic delete with undo toast (small `<aside>` element fixed
to bottom-centre, `setTimeout` 6s, calls `DELETE` only after timeout
expires).
- Verify the live deployed list still looks right after deploy.
### Commit 5 — `/admin/feedback/[id]` (detail) — second-biggest change
- `src/routes/admin/feedback/[id]/+page.svelte`:
- Header collapse per 3.5: status becomes `.fb-status-pill`, drop
raw `/f/slug` text, drop inline Copy/Preview/Close/Reopen/CSV/
JSON/Delete buttons.
- Add `.fb-share-strip` directly under the title/description in the
header (replaces the standalone Share section below).
- Add `.fb-menu` with Copy link / Export CSV / Export JSON /
────── / Delete.
- Delete the standalone `<section data-fb-share>` block — its
contents are now in the header strip + a "Replace" inline form.
- Inside Edit tab: drop the `<h2>Edit · v3</h2>`, move version pill
next to Save; turn Visual/JSON into a segmented pill control
matching `.fb-tabs`.
- Move `submissions.length` warning from `.fb-banner` to muted line.
- Replace inline `style="..."` with classes throughout.
### Commit 6 — `/f/[slug]` participant polish
- `src/routes/f/[slug]/+page.svelte`:
- Replace the inline-label name row with a stacked-label field.
- Increase gap between Chat and Form sections (use `--gap-section`).
- Switch "(closed)" banner to neutral muted style (a new
`.fb-status-line--muted` modifier on `.fb-banner` or a dedicated
`.fb-closed-line` class).
- Adjust `.fb-chat__post--mine` styling: drop full coloured border,
add `border-left: 3px solid var(--color-primary)` only.
- Wrap footer text in an `<a href="/">` with `aria-label="fdbck home"`.
### Commit 7 — sweep: optimistic toggles, hover-reveal actions
- Add the optimistic-update + undo-toast pattern to `setStatus` on the
detail page (status pill instant flip, server call after).
- Apply the same to per-row `toggleStatus` on the list page.
- Verify dark-mode pass: walk all five routes in dark mode (browser
emulator) since multiple inline `style="..."` strings were converted
to classes that inherit from `:root` / `prefers-color-scheme`.
---
## 5. Open questions for m
These are decisions m should weigh in on before implementation. Each has
my recommendation, but they're all reversible.
1. **Cards vs spacious list for `/admin/feedback`.** I'm proposing a
spacious list (no border, lots of whitespace, hover-revealed ``).
The brief mentions cards as an option. **My recommendation: list.**
Cards add visual chrome (border + shadow) which is the opposite of
"negative space". Cards earn their keep when each item has rich
content (image, multi-line preview); fdbck rows have title + 2
meta lines.
2. **Optimistic delete with 6s undo, or keep the `confirm()` modal?**
Today both list and detail use `confirm("…cannot be undone")`. The
undo toast is smoother but trades safety for fluency. **My
recommendation: undo toast** — the row reappears identically if
undone, the destructive call only fires after timeout, no data is
actually at risk during the 6s window. Keep `confirm()` only on
detail-page Delete (deleting from the detail page is a more
deliberate action and merits an extra step).
3. **Share-link strip in the detail header — does it belong there?**
I'm folding the standalone Share section *into* the header (3.5).
Alternative: keep Share as a section but move it *below* the tabs,
so the header stays minimal. **My recommendation: in the header.**
Sharing the link is the second-most-common action after viewing
results; it belongs near the title.
4. **Drop the Edit tab body's inner H2?** Today every tab body has an
`<h2>` echoing the tab name (`<h2>Live chat</h2>`,
`<h2>Results</h2>`, etc.). I want to drop these — the tab strip
already labels the active section. But this is a stylistic call.
**My recommendation: drop the inner H2s,** add `2rem` of top
padding to the tab body so the active tab pill alone titles the
section.
5. **Participant name field — top, bottom-as-footer, or bottom-as-
"edit"?** Today it's at the top with an inline label. I'm proposing
stacking it the same as other fields. A bolder alternative: move
it to a footer-style "Du schreibst als: Anonym ✎" below all
sections. **My recommendation for v1: stacked label, top.** Bolder
move waits for v2.
6. **Locale homogenisation.** Admin pages are English, participant
page is German. Out of scope for this redesign — flagging only so
we don't accidentally translate one of them while doing the markup
pass. Confirm this stays as-is.
7. **Accent on `--color-primary`.** Same green stays per brief. But
today some screens have `box-shadow: 0 0 0 3px rgb(22 163 74 /
0.15)` on focus and others have `0.25` opacity — minor inconsistency.
Worth normalising during commit 1? **My recommendation: yes,
normalise to one focus-ring rule (0.2 opacity, 3px).**
---
## 6. Anti-scope (explicitly NOT in this redesign)
- Palette: no new colours, no new accent.
- Stack: still SvelteKit + vanilla CSS + Inter. No new deps.
- Routes: unchanged URL structure.
- Data model: unchanged.
- Locale: no homogenisation (see Q6).
- Polling intervals (5s admin, 3s chat): unchanged.
- Auth flow / share-link backend: unchanged (only the *display* of the
share link in the detail header changes).

View File

@@ -23,6 +23,23 @@
--color-border-secondary: #d1d5db;
--color-border-focus: #16a34a;
/* Status pill tokens — surface background + foreground for open / closed */
--color-status-open-bg: var(--color-primary-light);
--color-status-open-fg: var(--color-primary-hover);
--color-status-closed-bg: #fef3c7;
--color-status-closed-fg: #78350f;
/* Spacing scale — single source of truth for vertical rhythm */
--space-1: 0.5rem;
--space-2: 0.75rem;
--space-3: 1rem;
--space-4: 1.25rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-7: 2.5rem;
--space-8: 3rem;
--space-9: 4rem;
/* Radii / shadows */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
@@ -73,6 +90,9 @@
--color-border-secondary: #4b5563;
--color-border-focus: #22c55e;
--color-status-closed-bg: rgba(245, 158, 11, 0.15);
--color-status-closed-fg: #fcd34d;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
@@ -160,7 +180,7 @@ body { min-height: 100vh; }
.fb-section {
padding: 0;
margin-bottom: 1.75rem;
margin-bottom: var(--space-7);
}
.fb-section h2 {
@@ -208,7 +228,7 @@ body { min-height: 100vh; }
.fb-select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.15);
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
}
.fb-input:hover:not(:focus),
@@ -218,7 +238,7 @@ body { min-height: 100vh; }
}
.fb-question {
margin-bottom: 1.25rem;
margin-bottom: var(--space-4);
}
.fb-question__label {
@@ -342,7 +362,7 @@ body { min-height: 100vh; }
.fb-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.25);
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
}
.fb-btn:disabled {
@@ -455,7 +475,8 @@ body { min-height: 100vh; }
.fb-chat__post--mine {
background: var(--color-primary-light);
border-color: var(--color-primary);
border-color: var(--color-border-primary);
border-left: 3px solid var(--color-primary);
}
.fb-chat__post--hidden {
@@ -792,3 +813,453 @@ body { min-height: 100vh; }
grid-template-columns: 6rem minmax(0, 1fr) auto;
}
}
/* ─────────────────────────────────────────────────────────────────
Minimalist redesign — utility classes added 2026-05-06.
See docs/plans/ui-redesign.md for the rationale.
───────────────────────────────────────────────────────────────── */
/* Page modifiers — vertical centering + narrow shell for landing/login. */
.fb-shell.fb-page-narrow {
max-width: 480px;
}
.fb-shell.fb-page-center {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 100vh;
padding-top: var(--space-6);
padding-bottom: var(--space-9);
}
/* Quiet text-only back-link — replaces the chip-style back button. */
.fb-back-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: var(--space-4);
}
.fb-back-link:hover {
color: var(--color-text-primary);
}
/* Inline error — quieter alternative to .fb-banner--error. */
.fb-inline-error {
color: var(--color-error);
font-size: 0.875rem;
margin-top: 0.6rem;
}
/* Toggle field — label-left, native checkbox-right (no UI library). */
.fb-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: var(--space-2) 0;
cursor: pointer;
}
.fb-toggle__text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.fb-toggle__label {
font-weight: 500;
font-size: 0.95rem;
}
.fb-toggle__hint {
font-size: 0.825rem;
color: var(--color-text-muted);
}
.fb-toggle input {
margin: 0;
flex-shrink: 0;
}
/* Status pill — clickable, toggles open/closed via the same control. */
.fb-status-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.65rem;
border: 1px solid transparent;
border-radius: 999px;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
user-select: none;
transition: filter 150ms ease, box-shadow 150ms ease;
}
.fb-status-pill:hover:not(:disabled) {
filter: brightness(0.97);
}
.fb-status-pill:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
}
.fb-status-pill:disabled {
cursor: wait;
opacity: 0.6;
}
.fb-status-pill__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: currentColor;
display: inline-block;
}
.fb-status-pill--open {
background: var(--color-status-open-bg);
color: var(--color-status-open-fg);
}
.fb-status-pill--closed {
background: var(--color-status-closed-bg);
color: var(--color-status-closed-fg);
}
/* ⋯ menu — native <details>/<summary> with an absolutely-positioned panel. */
.fb-menu {
position: relative;
display: inline-block;
}
.fb-menu__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
background: transparent;
color: var(--color-text-secondary);
font-size: 1.1rem;
font-weight: 700;
user-select: none;
list-style: none;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.fb-menu__btn::-webkit-details-marker { display: none; }
.fb-menu__btn::marker { content: ''; }
.fb-menu__btn:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.fb-menu__btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgb(22 163 74 / 0.2);
}
.fb-menu[open] > .fb-menu__btn {
background: var(--color-bg-tertiary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
}
.fb-menu__panel {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
z-index: 30;
min-width: 12rem;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: 0.3rem;
display: flex;
flex-direction: column;
}
.fb-menu__item {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 0.7rem;
border-radius: var(--radius-sm);
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
text-decoration: none;
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
font-weight: 500;
white-space: nowrap;
}
.fb-menu__item:hover:not(:disabled) {
background: var(--color-bg-tertiary);
}
.fb-menu__item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fb-menu__divider {
border: 0;
border-top: 1px solid var(--color-border-primary);
margin: 0.25rem 0;
}
.fb-menu__item--danger {
color: var(--color-error);
}
.fb-menu__item--danger:hover:not(:disabled) {
background: rgb(220 38 38 / 0.08);
}
@media (prefers-color-scheme: dark) {
.fb-menu__item--danger:hover:not(:disabled) {
background: rgb(248 113 113 / 0.15);
}
}
/* Card — the new list-row container. Subtle bg on the gradient page bg,
no border + shadow stack, generous internal padding. */
.fb-card {
background: var(--color-bg-primary);
padding: var(--space-5);
border-radius: var(--radius-lg);
}
.fb-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.fb-card__title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--color-text-primary);
text-decoration: none;
letter-spacing: -0.01em;
line-height: 1.35;
word-break: break-word;
min-width: 0;
}
.fb-card__title:hover {
color: var(--color-primary);
}
.fb-card__actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.fb-card__meta {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-top: 0.4rem;
line-height: 1.5;
}
.fb-card-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-5);
}
@media (min-width: 640px) {
.fb-card-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-6);
}
}
/* Empty state — generous whitespace + soft text. */
.fb-empty {
padding: var(--space-7) 0;
text-align: center;
color: var(--color-text-muted);
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: center;
}
/* Inline share strip — replaces the standalone Share section in the
detail-page header. Subtle border so the URL reads as a chip. */
.fb-share-strip {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: var(--space-3);
padding: 0.55rem 0.75rem;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
}
.fb-share-strip__url {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 0.9rem;
color: var(--color-text-primary);
text-decoration: none;
word-break: break-all;
flex: 1;
min-width: 0;
}
.fb-share-strip__url:hover {
color: var(--color-primary);
}
.fb-share-strip__placeholder {
color: var(--color-text-muted);
font-size: 0.875rem;
flex: 1;
}
.fb-share-strip__replace {
margin-top: var(--space-3);
}
.fb-share-strip__replace summary {
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: var(--space-2);
}
.fb-share-strip__replace summary:hover {
color: var(--color-text-primary);
}
/* Closed-state line — neutral muted, replaces .fb-banner--closed at the
top of the participant page so closed reads as a state, not an alert. */
.fb-closed-line {
display: flex;
align-items: center;
gap: 0.5rem;
padding: var(--space-2) 0;
border-top: 1px solid var(--color-border-primary);
border-bottom: 1px solid var(--color-border-primary);
color: var(--color-text-muted);
font-size: 0.9rem;
font-style: italic;
margin-bottom: var(--space-5);
}
/* Segmented control — small variant of .fb-tabs for inline controls
(e.g. Visual / JSON toggle on the Edit tab). */
.fb-segment {
display: inline-flex;
gap: 0.2rem;
padding: 0.2rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
}
.fb-segment__btn {
background: transparent;
border: 0;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 500;
font-family: inherit;
transition: background 150ms ease, color 150ms ease;
}
.fb-segment__btn:hover:not(.fb-segment__btn--active) {
color: var(--color-text-primary);
}
.fb-segment__btn--active {
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-weight: 600;
box-shadow: var(--shadow-sm);
}
/* Detail-page header — denser controls cluster with whitespace below. */
.fb-detail-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-3);
}
.fb-detail-head__title {
flex: 1;
min-width: 0;
}
.fb-detail-head__actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Tab body — generous top padding so the tab pill alone titles the section. */
.fb-tab-body {
padding-top: var(--space-6);
}
/* Saved-version note — quiet line above the Save button on the Edit tab. */
.fb-version-note {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.4rem;
}
.fb-save-row {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-top: var(--space-4);
}

View File

@@ -1,20 +1,45 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
</script>
<svelte:head>
<title>fdbck — feedback by link</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<h1>fdbck</h1>
<p>
Private feedback forms and live chat — share a link, get answers.
</p>
<div class="fb-shell fb-page-narrow fb-page-center">
<header class="fb-header fb-landing">
<h1 class="fb-landing__wordmark">fdbck</h1>
<p class="fb-landing__tagline">feedback by link</p>
</header>
<div class="fb-section">
<p>This page is only reachable through a private link shared with you.</p>
<p style="margin-top: 0.75rem;">
<a href="/login" class="fb-btn fb-btn--ghost">Admin sign-in</a>
</p>
<div class="fb-landing__cta">
<a href="/login" class="fb-btn fb-btn--ghost fb-btn--lg">
Admin sign-in <Icon name="arrow-right" />
</a>
</div>
</div>
<style>
.fb-landing {
text-align: center;
margin-bottom: var(--space-8);
}
.fb-landing__wordmark {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.03em;
margin: 0;
line-height: 1;
}
.fb-landing__tagline {
color: var(--color-text-muted);
font-size: 1rem;
font-weight: 400;
margin: 0.5rem 0 0;
}
.fb-landing__cta {
display: flex;
justify-content: center;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import '$lib/styles/feedback.css';
import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte';
import { invalidateAll } from '$app/navigation';
import Icon from '$lib/components/Icon.svelte';
@@ -49,9 +50,18 @@
}
}
// Optimistic status toggle — flip the pill instantly, then PATCH. On
// failure, revert by calling invalidateAll() (server has the truth).
let optimisticStatus = $state<Record<string, 'open' | 'closed'>>({});
function effectiveStatus(i: { id: string; status: 'open' | 'closed' }): 'open' | 'closed' {
return optimisticStatus[i.id] ?? i.status;
}
async function toggleStatus(id: string, current: 'open' | 'closed'): Promise<void> {
if (rowBusy[id]) return;
const next = current === 'open' ? 'closed' : 'open';
optimisticStatus = { ...optimisticStatus, [id]: next };
rowBusy = { ...rowBusy, [id]: true };
setRowError(id, null);
try {
@@ -63,11 +73,17 @@
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
setRowError(id, j.error ?? `Error ${res.status}`);
const { [id]: _, ...rest } = optimisticStatus;
optimisticStatus = rest;
return;
}
await invalidateAll();
const { [id]: _, ...rest } = optimisticStatus;
optimisticStatus = rest;
} catch (err) {
setRowError(id, err instanceof Error ? err.message : 'Network error');
const { [id]: _, ...rest } = optimisticStatus;
optimisticStatus = rest;
} finally {
const { [id]: _, ...rest } = rowBusy;
rowBusy = rest;
@@ -82,6 +98,43 @@
return '—';
}
function subtitleFor(i: PageData['instances'][number]): string {
const parts: string[] = [modeLabel(i)];
parts.push(`${i.counts.submissions} ${i.counts.submissions === 1 ? 'response' : 'responses'}`);
if (i.chat_enabled) {
parts.push(`${i.counts.posts} ${i.counts.posts === 1 ? 'message' : 'messages'}`);
}
return parts.join(' · ');
}
// Click-outside-to-close for any open <details class="fb-menu">. Native
// <details> only toggles via summary clicks; this adds the dismiss-on-
// outside-click pattern users expect from dropdown menus.
function onDocClick(e: MouseEvent): void {
const target = e.target as Node | null;
if (!target) return;
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
if (!d.contains(target)) d.open = false;
});
}
function onDocKey(e: KeyboardEvent): void {
if (e.key !== 'Escape') return;
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
d.open = false;
});
}
onMount(() => {
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onDocKey);
});
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocKey);
}
});
</script>
<svelte:head>
@@ -90,72 +143,81 @@
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<div style="display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap;">
<div>
<h1 style="margin: 0 0 0.25rem;">Feedback forms</h1>
<p style="margin: 0;">Collect feedback through forms or live chat. Share a private link with your audience.</p>
</div>
<a href="/admin/feedback/new" class="fb-btn"><Icon name="plus" /> New form</a>
</div>
<header class="fb-header fb-list-head">
<h1>Forms</h1>
<a href="/admin/feedback/new" class="fb-btn"><Icon name="plus" /> New form</a>
</header>
<section class="fb-section">
<h2>Your forms ({data.instances.length})</h2>
{#if data.instances.length === 0}
<p style="color: var(--color-text-muted);">No forms yet.</p>
{:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{#if data.instances.length === 0}
<div class="fb-empty">
<p>No forms yet.</p>
<a href="/admin/feedback/new" class="fb-btn"><Icon name="plus" /> Create your first form</a>
</div>
{:else}
<section class="fb-section">
<div class="fb-card-grid">
{#each data.instances as i (i.id)}
<div style="border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem; background: var(--color-bg-primary); box-shadow: var(--shadow-sm);">
<div style="display: flex; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap;">
<div style="min-width: 0; flex: 1;">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
<a href="/admin/feedback/{i.id}" style="font-weight: 600; color: var(--color-text-primary); text-decoration: none;">{i.title}</a>
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border: 1px solid var(--color-border-primary); border-radius: 999px; color: var(--color-text-muted);">
{modeLabel(i)}
</span>
{#if i.status === 'closed'}
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #fef3c7; color: #78350f;">closed</span>
{:else}
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: var(--color-primary-light); color: var(--color-primary-hover);">open</span>
{/if}
</div>
<div style="font-size: 0.85rem; color: var(--color-text-muted); margin-top: 0.2rem;">
{i.counts.submissions} responses · {i.counts.posts} messages · created {new Date(i.created_at).toLocaleDateString()}
</div>
<div style="font-size: 0.78rem; color: var(--color-text-muted); margin-top: 0.2rem; word-break: break-all;">
/f/{i.slug}
</div>
</div>
<div style="display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: flex-start;">
<a class="fb-btn fb-btn--secondary fb-btn--sm" href="/admin/feedback/{i.id}"><Icon name="edit" /> Edit</a>
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={() => copyLink(i.slug)}><Icon name="copy" /> Copy link</button>
<a class="fb-btn fb-btn--ghost fb-btn--sm" href="/f/{i.slug}" target="_blank" rel="noopener"><Icon name="external-link" /> Open</a>
{@const status = effectiveStatus(i)}
<div class="fb-card">
<div class="fb-card__head">
<a class="fb-card__title" href="/admin/feedback/{i.id}">{i.title}</a>
<div class="fb-card__actions">
<button
type="button"
class="fb-btn fb-btn--ghost fb-btn--sm"
class="fb-status-pill fb-status-pill--{status}"
disabled={rowBusy[i.id]}
onclick={() => toggleStatus(i.id, i.status)}
onclick={() => toggleStatus(i.id, status)}
aria-label={status === 'open' ? 'Open — click to close' : 'Closed — click to reopen'}
title={status === 'open' ? 'Click to close' : 'Click to reopen'}
>
{#if i.status === 'open'}<Icon name="lock" /> Close{:else}<Icon name="unlock" /> Reopen{/if}
</button>
<button
type="button"
class="fb-btn fb-btn--danger fb-btn--sm"
disabled={rowBusy[i.id]}
onclick={() => destroyInstance(i.id, i.title)}
>
<Icon name="trash" /> Delete
<span class="fb-status-pill__dot"></span>{status}
</button>
<details class="fb-menu">
<!-- svelte-ignore a11y_no_redundant_roles -->
<summary class="fb-menu__btn" role="button" aria-label="More actions"></summary>
<div class="fb-menu__panel" role="menu">
<button type="button" class="fb-menu__item" onclick={() => copyLink(i.slug)}>
<Icon name="copy" /> Copy link
</button>
<a class="fb-menu__item" href="/f/{i.slug}" target="_blank" rel="noopener">
<Icon name="external-link" /> Open
</a>
<a class="fb-menu__item" href="/admin/feedback/{i.id}">
<Icon name="edit" /> Edit
</a>
<hr class="fb-menu__divider" />
<button
type="button"
class="fb-menu__item fb-menu__item--danger"
disabled={rowBusy[i.id]}
onclick={() => destroyInstance(i.id, i.title)}
>
<Icon name="trash" /> Delete
</button>
</div>
</details>
</div>
</div>
<div class="fb-card__meta">{subtitleFor(i)}</div>
{#if rowError[i.id]}
<div class="fb-banner fb-banner--error" style="margin-top: 0.6rem; margin-bottom: 0;">{rowError[i.id]}</div>
<div class="fb-inline-error">{rowError[i.id]}</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
</section>
{/if}
</div>
<style>
.fb-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.fb-list-head h1 {
margin: 0;
}
</style>

View File

@@ -36,6 +36,10 @@
);
let editFormJson = $state(inst.form_definition ? JSON.stringify(inst.form_definition, null, 2) : '');
// Optimistic status pill — same pattern as the list page.
let optimisticStatus = $state<'open' | 'closed' | null>(null);
const effectiveStatus = $derived<'open' | 'closed'>(optimisticStatus ?? inst.status);
function syncJsonFromVisual(): void {
editFormJson = editForm ? JSON.stringify(editForm, null, 2) : '';
}
@@ -112,9 +116,13 @@
}
}
async function setStatus(next: 'open' | 'closed'): Promise<void> {
actionError = null;
async function toggleStatus(): Promise<void> {
if (actionInFlight) return;
const current = effectiveStatus;
const next: 'open' | 'closed' = current === 'open' ? 'closed' : 'open';
optimisticStatus = next;
actionInFlight = true;
actionError = null;
try {
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
method: 'PATCH',
@@ -124,11 +132,14 @@
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
actionError = j.error ?? `Error ${res.status}`;
optimisticStatus = null;
return;
}
await refresh();
optimisticStatus = null;
} catch (e) {
actionError = e instanceof Error ? e.message : 'Network error';
optimisticStatus = null;
} finally {
actionInFlight = false;
}
@@ -230,6 +241,16 @@
}
}
async function copyShareStripUrl(): Promise<void> {
if (inst.short_url) {
await copyShortUrl();
} else {
await copyLink();
shareCopied = true;
setTimeout(() => (shareCopied = false), 1500);
}
}
async function createShareLink(): Promise<void> {
shareError = null;
shareInFlight = true;
@@ -259,6 +280,14 @@
}
}
async function exportCsv(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
}
async function exportJson(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString();
}
@@ -274,16 +303,25 @@
return summarizeAnswer(sub.answers?.[qid]);
}
async function exportCsv(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
// Click-outside-to-close + Escape for any open <details class="fb-menu">.
function onDocClick(e: MouseEvent): void {
const target = e.target as Node | null;
if (!target) return;
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
if (!d.contains(target)) d.open = false;
});
}
async function exportJson(): Promise<void> {
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
function onDocKey(e: KeyboardEvent): void {
if (e.key !== 'Escape') return;
document.querySelectorAll<HTMLDetailsElement>('details.fb-menu[open]').forEach((d) => {
d.open = false;
});
}
onMount(() => {
pollHandle = setInterval(refresh, 5000);
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onDocKey);
return () => {
if (pollHandle) clearInterval(pollHandle);
};
@@ -291,6 +329,10 @@
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
if (typeof document !== 'undefined') {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocKey);
}
void invalidateAll;
});
</script>
@@ -301,104 +343,95 @@
</svelte:head>
<div class="fb-shell">
<a href="/admin/feedback" class="fb-back-link">
<Icon name="arrow-left" /> Forms
</a>
<header class="fb-header">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/feedback" class="fb-btn fb-btn--ghost fb-btn--sm"><Icon name="arrow-left" /> All forms</a>
</div>
<h1 style="margin-top: 0.5rem;">{inst.title}</h1>
{#if inst.description}<p>{inst.description}</p>{/if}
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 0.5rem;">
<span style="font-size: 0.85rem; padding: 0.2rem 0.6rem; border-radius: 999px; font-weight: 500; {inst.status === 'closed' ? 'background:#fef3c7; color:#78350f;' : 'background:var(--color-primary-light); color:var(--color-primary-hover);'}">
{inst.status}
</span>
<span style="font-size: 0.85rem; color: var(--color-text-muted);">/f/{inst.slug}</span>
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={copyLink}><Icon name="copy" /> Copy link</button>
<a class="fb-btn fb-btn--ghost fb-btn--sm" href="/f/{inst.slug}" target="_blank" rel="noopener"><Icon name="external-link" /> Preview</a>
{#if inst.status === 'open'}
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" disabled={actionInFlight} onclick={() => setStatus('closed')}>
<Icon name="lock" /> Close
<div class="fb-detail-head">
<div class="fb-detail-head__title">
<h1>{inst.title}</h1>
{#if inst.description}<p>{inst.description}</p>{/if}
</div>
<div class="fb-detail-head__actions">
<button
type="button"
class="fb-status-pill fb-status-pill--{effectiveStatus}"
disabled={actionInFlight}
onclick={toggleStatus}
aria-label={effectiveStatus === 'open' ? 'Open — click to close' : 'Closed — click to reopen'}
title={effectiveStatus === 'open' ? 'Click to close' : 'Click to reopen'}
>
<span class="fb-status-pill__dot"></span>{effectiveStatus}
</button>
{:else}
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" disabled={actionInFlight} onclick={() => setStatus('open')}>
<Icon name="unlock" /> Reopen
</button>
{/if}
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={exportCsv}><Icon name="download" /> CSV</button>
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={exportJson}><Icon name="download" /> JSON</button>
<button type="button" class="fb-btn fb-btn--danger fb-btn--sm" disabled={actionInFlight} onclick={destroy}><Icon name="trash" /> Delete</button>
<details class="fb-menu">
<!-- svelte-ignore a11y_no_redundant_roles -->
<summary class="fb-menu__btn" role="button" aria-label="More actions"></summary>
<div class="fb-menu__panel" role="menu">
<button type="button" class="fb-menu__item" onclick={copyLink}>
<Icon name="copy" /> Copy /f/{inst.slug}
</button>
<button type="button" class="fb-menu__item" onclick={exportCsv}>
<Icon name="download" /> Export CSV
</button>
<button type="button" class="fb-menu__item" onclick={exportJson}>
<Icon name="download" /> Export JSON
</button>
<hr class="fb-menu__divider" />
<button
type="button"
class="fb-menu__item fb-menu__item--danger"
disabled={actionInFlight}
onclick={destroy}
>
<Icon name="trash" /> Delete
</button>
</div>
</details>
</div>
</div>
</header>
{#if actionError}
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<section class="fb-section" data-fb-share>
<h2>Share</h2>
{#if inst.short_url}
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
Memorable short link — resolves to <code>/f/{inst.slug}</code>.
</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<div class="fb-share-strip">
{#if inst.short_url}
<a
class="fb-share-strip__url"
href={inst.short_url}
target="_blank"
rel="noopener"
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.95rem; word-break: break-all;"
>
{inst.short_url}
</a>
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyShortUrl}>
{shareCopied ? 'Copied' : 'Copy'}
</button>
<a class="fb-btn fb-btn--ghost" href={inst.short_url} target="_blank" rel="noopener">
Open ↗
</a>
</div>
<details style="margin-top: 0.75rem;">
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.85rem;">
Replace with a different short link
</summary>
<div style="margin-top: 0.5rem;">
<label class="fb-question__label" for="fb-share-slug-replace">Custom slug (optional)</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<input
id="fb-share-slug-replace"
class="fb-input"
maxlength="64"
placeholder="e.g. vote, session-feedback"
bind:value={shareSlugInput}
style="max-width: 320px;"
/>
<button
type="button"
class="fb-btn"
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : 'Create new'}
</button>
</div>
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
</p>
</div>
</details>
{:else}
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
Create a memorable short link (e.g. <code>https://msbls.de/vote</code>) that redirects to this form.
</p>
<div style="margin-top: 0.5rem;">
>{inst.short_url}</a>
{:else}
<a
class="fb-share-strip__url"
href={`/f/${inst.slug}`}
target="_blank"
rel="noopener"
>/f/{inst.slug}</a>
{/if}
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={copyShareStripUrl}>
<Icon name="copy" /> {shareCopied ? 'Copied' : 'Copy'}
</button>
<a
class="fb-btn fb-btn--ghost fb-btn--sm"
href={inst.short_url ?? `/f/${inst.slug}`}
target="_blank"
rel="noopener"
>
<Icon name="external-link" /> Open
</a>
</div>
<details class="fb-share-strip__replace">
<summary>{inst.short_url ? 'Replace short link' : 'Create memorable short link'}</summary>
<div>
<label class="fb-question__label" for="fb-share-slug">Custom slug (optional)</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<div class="fb-save-row" style="margin-top: 0.4rem;">
<input
id="fb-share-slug"
class="fb-input"
maxlength="64"
placeholder="e.g. vote, session-feedback"
bind:value={shareSlugInput}
style="max-width: 320px;"
style="max-width: 320px; flex: 1;"
/>
<button
type="button"
@@ -406,18 +439,23 @@
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : 'Create short link'}
{shareInFlight ? 'Creating…' : (inst.short_url ? 'Replace' : 'Create')}
</button>
</div>
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
<p class="fb-question__help" style="margin-top: 0.4rem;">
Leave blank for a random short code, or pick a memorable slug like
<code>vote</code> or <code>session-feedback</code>.
</p>
{#if shareError}
<div class="fb-inline-error">{shareError}</div>
{/if}
</div>
{/if}
{#if shareError}
<div class="fb-banner fb-banner--error" style="margin-top: 0.5rem;">{shareError}</div>
{/if}
</section>
</details>
</header>
{#if actionError}
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<div class="fb-tabs">
{#each [
@@ -440,12 +478,11 @@
</div>
{#if activeTab === 'chat'}
<section class="fb-section">
<h2>Live chat</h2>
<section class="fb-section fb-tab-body">
{#if posts.length === 0}
<p style="color: var(--color-text-muted);">No messages yet.</p>
<p class="fb-empty">No messages yet.</p>
{:else}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<div class="fb-detail-list">
{#each posts as p (p.id)}
<div class="fb-chat__post {p.hidden ? 'fb-chat__post--hidden' : ''}">
<div class="fb-chat__meta">
@@ -470,40 +507,36 @@
{/if}
</section>
{:else if activeTab === 'results'}
<section class="fb-section">
<h2>Results{formVersion ? ` · v${formVersion}` : ''}</h2>
<section class="fb-section fb-tab-body">
{#if results}
<Results {results} />
{:else}
<p style="color: var(--color-text-muted);">No questions configured.</p>
<p class="fb-empty">No questions configured.</p>
{/if}
</section>
{:else if activeTab === 'submissions'}
<section class="fb-section">
<h2>Responses</h2>
<section class="fb-section fb-tab-body">
{#if submissions.length === 0}
<p style="color: var(--color-text-muted);">No responses yet.</p>
<p class="fb-empty">No responses yet.</p>
{:else}
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<div class="fb-detail-table-wrap">
<table class="fb-detail-table">
<thead>
<tr style="border-bottom: 1px solid var(--color-border-primary);">
<th style="text-align: left; padding: 0.5rem 0.4rem;">When</th>
<th style="text-align: left; padding: 0.5rem 0.4rem;">Name</th>
<tr>
<th>When</th>
<th>Name</th>
{#each questions as q (q.id)}
<th style="text-align: left; padding: 0.5rem 0.4rem;">{q.label}</th>
<th>{q.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each submissions as s (s.id)}
<tr style="border-bottom: 1px solid var(--color-border-primary);">
<td style="padding: 0.5rem 0.4rem; white-space: nowrap; color: var(--color-text-muted);">{fmtDateTime(s.created_at)}</td>
<td style="padding: 0.5rem 0.4rem;">{s.display_name ?? 'anonymous'}</td>
<tr>
<td class="fb-detail-table__date">{fmtDateTime(s.created_at)}</td>
<td>{s.display_name ?? 'anonymous'}</td>
{#each questions as q (q.id)}
<td style="padding: 0.5rem 0.4rem; max-width: 320px; word-break: break-word;">
{answerCellFor(q.id, s)}
</td>
<td class="fb-detail-table__cell">{answerCellFor(q.id, s)}</td>
{/each}
</tr>
{/each}
@@ -513,12 +546,12 @@
{/if}
</section>
{:else if activeTab === 'edit'}
<section class="fb-section">
<h2>Edit{formVersion ? ` · v${formVersion}` : ''}</h2>
<section class="fb-section fb-tab-body">
{#if submissions.length > 0}
<div class="fb-banner">
{submissions.length} responses already received. Saving will automatically bump the version — earlier responses keep their original snapshot.
</div>
<p class="fb-question__help" style="margin-bottom: var(--space-4);">
{submissions.length} responses already received. Saving will automatically
bump the version — earlier responses keep their original snapshot.
</p>
{/if}
<div class="fb-question">
<label class="fb-question__label" for="fb-edit-title">Title</label>
@@ -528,31 +561,37 @@
<label class="fb-question__label" for="fb-edit-desc">Description</label>
<textarea id="fb-edit-desc" class="fb-textarea" maxlength="2000" rows="2" bind:value={editDescription}></textarea>
</div>
<div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={editChatEnabled} />
<span>Enable live chat</span>
</label>
</div>
<div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={editLiveResults} />
<span>Show live results on the participant page after submitting</span>
</label>
</div>
<div class="fb-question">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap;">
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Live chat</span>
<span class="fb-toggle__hint">Let participants post messages in real time.</span>
</span>
<input type="checkbox" bind:checked={editChatEnabled} />
</label>
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Live results</span>
<span class="fb-toggle__hint">Show participants the live aggregate after they submit.</span>
</span>
<input type="checkbox" bind:checked={editLiveResults} />
</label>
<div class="fb-question" style="margin-top: var(--space-5);">
<div class="fb-edit-questions-head">
<span class="fb-question__label" style="margin: 0;">Questions</span>
<div style="display: inline-flex; gap: 0.25rem; margin-left: auto;">
<div class="fb-segment" role="tablist" aria-label="Editor mode">
<button
type="button"
class="fb-btn fb-btn--sm {editMode === 'visual' ? 'fb-btn--secondary' : 'fb-btn--ghost'}"
class="fb-segment__btn"
class:fb-segment__btn--active={editMode === 'visual'}
onclick={() => switchEditMode('visual')}
>Visual</button>
<button
type="button"
class="fb-btn fb-btn--sm {editMode === 'json' ? 'fb-btn--secondary' : 'fb-btn--ghost'}"
class="fb-segment__btn"
class:fb-segment__btn--active={editMode === 'json'}
onclick={() => switchEditMode('json')}
>JSON</button>
</div>
@@ -562,8 +601,10 @@
{#if editForm}
<FormBuilder bind:value={editForm as FeedbackFormDefinition} />
{:else}
<p style="color: var(--color-text-muted);">No questions configured.</p>
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={ensureBuilderForm}><Icon name="plus" /> Add questions</button>
<p class="fb-empty">No questions configured.</p>
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={ensureBuilderForm}>
<Icon name="plus" /> Add questions
</button>
{/if}
{:else}
<textarea
@@ -576,9 +617,57 @@
{/if}
</div>
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
<Icon name="check" /> {actionInFlight ? 'Saving…' : 'Save'}
</button>
<div class="fb-save-row">
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
<Icon name="check" /> {actionInFlight ? 'Saving…' : 'Save'}
</button>
{#if formVersion !== null}
<span class="fb-version-note">Current version: v{formVersion}</span>
{/if}
</div>
</section>
{/if}
</div>
<style>
.fb-detail-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.fb-detail-table-wrap {
overflow-x: auto;
}
.fb-detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.fb-detail-table thead tr {
border-bottom: 1px solid var(--color-border-primary);
}
.fb-detail-table tbody tr {
border-bottom: 1px solid var(--color-border-primary);
}
.fb-detail-table th,
.fb-detail-table td {
text-align: left;
padding: 0.5rem 0.4rem;
}
.fb-detail-table__date {
white-space: nowrap;
color: var(--color-text-muted);
}
.fb-detail-table__cell {
max-width: 320px;
word-break: break-word;
}
.fb-edit-questions-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
flex-wrap: wrap;
}
</style>

View File

@@ -80,12 +80,12 @@
</svelte:head>
<div class="fb-shell">
<a href="/admin/feedback" class="fb-back-link">
<Icon name="arrow-left" /> Forms
</a>
<header class="fb-header">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/feedback" class="fb-btn fb-btn--ghost fb-btn--sm"><Icon name="arrow-left" /> All forms</a>
</div>
<h1 style="margin-top: 0.5rem;">Create a new form</h1>
<p>Set up a feedback form, a live chat session, or both. You'll get a private link to share.</p>
<h1>New form</h1>
</header>
<section class="fb-section">
@@ -112,17 +112,23 @@
></textarea>
</div>
<div class="fb-question">
<label class="fb-option-row" style="display:inline-flex;">
<input type="checkbox" bind:checked={chatEnabled} />
<span>Enable live chat</span>
</label>
</div>
<label class="fb-toggle">
<span class="fb-toggle__text">
<span class="fb-toggle__label">Live chat</span>
<span class="fb-toggle__hint">Let participants post messages in real time.</span>
</span>
<input type="checkbox" bind:checked={chatEnabled} />
</label>
<div class="fb-question">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.4rem;">
<label class="fb-question__label" for="fb-new-form">Questions (JSON, optional)</label>
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={pasteSample}><Icon name="plus" /> Insert sample</button>
<details class="fb-question fb-new-advanced">
<summary>Add questions now (advanced)</summary>
<p class="fb-question__help" style="margin-top: 0.6rem;">
You can also edit questions visually after the form is created.
</p>
<div class="fb-save-row" style="margin-top: 0.75rem;">
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm" onclick={pasteSample}>
<Icon name="plus" /> Insert sample
</button>
</div>
<textarea
id="fb-new-form"
@@ -130,12 +136,9 @@
rows="14"
placeholder={'{\n "questions": [\n { "id": "q1", "type": "short_text", "label": "Your name?" }\n ]\n}'}
bind:value={formJson}
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem; margin-top: 0.6rem;"
></textarea>
<div class="fb-question__help">
Leave empty for chat-only feedback. You can edit questions visually after the form is created.
</div>
</div>
</details>
{#if createError}
<div class="fb-banner fb-banner--error">{createError}</div>
@@ -147,3 +150,20 @@
</form>
</section>
</div>
<style>
.fb-new-advanced > summary {
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.875rem;
font-weight: 500;
padding: 0.4rem 0;
user-select: none;
}
.fb-new-advanced > summary:hover {
color: var(--color-text-primary);
}
.fb-new-advanced[open] > summary {
color: var(--color-text-primary);
}
</style>

View File

@@ -303,14 +303,14 @@
</header>
{#if isClosed || chatStatus === 'closed'}
<div class="fb-banner fb-banner--closed">
<div class="fb-closed-line">
Diese Feedback-Sitzung ist geschlossen — neue Beiträge sind nicht mehr möglich.
</div>
{/if}
<div class="fb-section">
<div class="fb-name-row">
<label for="fb-name">Dein Name (optional):</label>
<section class="fb-section">
<div class="fb-question">
<label class="fb-question__label" for="fb-name">Dein Name (optional)</label>
<input
id="fb-name"
type="text"
@@ -321,7 +321,7 @@
oninput={saveName}
/>
</div>
</div>
</section>
{#if chatEnabled}
<section class="fb-section" aria-label="Live-Chat">
@@ -543,5 +543,15 @@
</section>
{/if}
<div class="fb-foot">fdbck.msbls.de</div>
<div class="fb-foot"><a href="/" class="fb-foot__link">fdbck.msbls.de</a></div>
</div>
<style>
.fb-foot__link {
color: inherit;
text-decoration: none;
}
.fb-foot__link:hover {
color: var(--color-text-primary);
}
</style>

View File

@@ -39,10 +39,9 @@
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<div class="fb-shell fb-page-narrow fb-page-center">
<header class="fb-header">
<h1>Sign in</h1>
<p>Admin access only.</p>
</header>
<section class="fb-section">
@@ -70,14 +69,14 @@
/>
</div>
{#if error}
<div class="fb-banner fb-banner--error">{error}</div>
{/if}
<button type="submit" class="fb-btn" disabled={inFlight}>
{inFlight ? 'Signing in…' : 'Sign in'}
<Icon name="arrow-right" />
</button>
{#if error}
<div class="fb-inline-error" role="alert">{error}</div>
{/if}
</form>
</section>
</div>